;;; Code: (require 'json) (require 'plz) (require 'message) (require 'rx) (defgroup discoursel nil "Discourse TODO") (defcustom discoursel-instance "https://discourse.ubuntu.com" "The Discourse instance." :group 'discoursel :type 'string) (defcustom discoursel-fetch-all-posts-from-topic t "Whether to fetch all posts from a certain topic. If t, the default, all posts are fetched (which may take a while depending on the topic's length). If nil, pagination is enabled and the user needs to ask for more posts manually and incrementally." :group 'discoursel :type 'boolean) (defvar discoursel-buffer-list nil "List of discoursel buffers.") (defface discoursel-topic-face '((((class color) (background light)) (:foreground "#000")) (((class color) (background dark)) (:foreground "#fff"))) "Face used for topics." :group 'discoursel) (defface discoursel-username-face '((((class color) (background light)) (:foreground "#aa0")) (((class color) (background dark)) (:foreground "#ff0"))) "Face used for usernames." :group 'discoursel) (defface discoursel-code-face '((((class color) (background light)) (:background "#cdcecc" :extend t)) (((class color) (background dark)) (:background "#17211c" :extend t))) "Face used for code blocks." :group 'discoursel) (defun discoursel--parse-pre (tag) (let ((p (point)) (shr-folding-mode 'none)) (shr-ensure-newline) (shr-generic tag) (shr-ensure-newline) (add-text-properties p (point) '(pre-tag t face discoursel-code-face)))) (defun discoursel--get (endpoint) (condition-case plzerror (plz 'get (concat discoursel-instance "/" endpoint ".json") :as #'json-read) (plz-error (error "Could not query endpoint '%s': '%s'" endpoint (plz-error-message plzerror))))) (defun discoursel--latest-topics () (let* ((r (discoursel--get "latest")) (topics (alist-get 'topics (alist-get 'topic_list r))) (users (alist-get 'users r))) (with-current-buffer (discoursel-get-create-buffer) (cl-map 'vector (lambda (topic) (let* ((userid (alist-get 'user_id (aref (alist-get 'posters topic) 0))) (username (seq-find (lambda (v) (= (alist-get 'id v) userid)) users)) (inhibit-read-only t)) (insert (propertize (format "%s (by %s)\n" (propertize (alist-get 'title topic) 'face 'discoursel-topic-face) (propertize (alist-get 'name username) 'face 'discoursel-username-face)) 'discoursel-topic-slug (alist-get 'slug topic) 'discoursel-topic-id (alist-get 'id topic))))) topics)))) ;;;###autoload (defun discoursel-update () (interactive) (with-current-buffer (discoursel-get-create-buffer) (save-excursion (let ((inhibit-read-only t)) (discoursel--latest-topics))))) (defun discoursel--get-name-or-username (post) (let ((name (alist-get 'display_username post))) (if (and name (not (string-empty-p name))) name (alist-get 'username post)))) (defun discoursel--parse-heading (dom) "Prepend tag (DOM) with the equivalent number of sharp (#) characters." (let* ((idx (1- (length dom))) ;; This is a bit hacky and assumes that the text will always ;; be available as the last item in the list. (tagname (symbol-name (car dom))) (level (string-to-number (string-remove-prefix "h" tagname))) (sharps (make-string level ?#)) (face (intern (concat "shr-" tagname))) (possible-text (nth idx dom))) (when (stringp possible-text) (setf (nth idx dom) (concat sharps " " possible-text))) (shr-heading dom face))) (defun discoursel-topic-next-entry () (interactive) (let ((pos (point))) (save-excursion (setq pos (next-single-char-property-change pos 'post-id)) (goto-char pos) (while (and (not (eobp)) (not (looking-at-p (rx line-start "Author:")))) (setq pos (next-single-char-property-change pos 'post-id)) (goto-char pos)) (when (eobp) (setq pos nil))) (when pos (goto-char pos)))) (defun discoursel-topic-previous-entry () (interactive) (let ((pos (point))) (setq pos (or (previous-single-property-change pos 'post-id) (point-min))) (goto-char pos) (while (and (not (bobp)) (not (looking-at-p (rx line-start (or "Author:" "Title:"))))) (setq pos (or (previous-single-property-change pos 'post-id) (point-min))) (goto-char pos)))) (defun discoursel--insert-post (post &optional post-title) (let* ((post-slug (alist-get 'topic_slug post)) (post-id (alist-get 'id post)) ; (topic-title (alist-get 'title r)) (post-date (alist-get 'created_at post)) (post-date-string (format-time-string "%c" (encode-time (parse-time-string post-date)))) (post-author (discoursel--get-name-or-username post)) (post-html-content (alist-get 'cooked post))) ;; (userid (alist-get 'user_id r)) ;; (participants (alist-get 'participants (alist-get 'details r))) ;; (topic-username (alist-get 'username first-post)) ;; (topic-author-alist (seq-find (lambda (p) ;; (string= (alist-get 'username p) topic-username)) ;; participants)) ;; ; (topic-author (alist-get 'username topic-author-alist)) ;; (topic-flairname (alist-get 'name topic-author-alist)) ;; (topic-author (concat topic-flairname " ("(alist-get 'username first-post) ")")) (save-excursion (insert (with-temp-buffer (when post-title (insert (format (propertize "Title: %s\n" 'face 'message-header-name) (propertize post-title 'face 'message-header-subject)))) (insert (format (propertize "Author: %s\n" 'face 'message-header-name) (propertize post-author 'face 'message-header-to))) (insert (format (propertize "Date: %s\n" 'face 'message-header-name) (propertize post-date-string 'face 'message-header-other))) (insert "\n") (save-excursion (insert post-html-content)) (let ((shr-use-fonts nil) (shr-external-rendering-functions '((pre . discoursel--parse-pre) (h1 . discoursel--parse-heading) (h2 . discoursel--parse-heading) (h3 . discoursel--parse-heading) (h4 . discoursel--parse-heading) (h5 . discoursel--parse-heading) (h6 . discoursel--parse-heading)))) (shr-render-region (point) (point-max))) (put-text-property (point-min) (point-max) 'post-id post-id) (buffer-string)))))) (defun discoursel--open-topic (&optional id) (interactive) (when (and (not (eq major-mode 'discoursel-mode)) (not id)) (user-error "You must be in a discoursel buffer")) (let* ((topic-id (number-to-string (or id (get-text-property (point) 'discoursel-topic-id)))) (r (discoursel--get (concat "t/" topic-id))) (posts (alist-get 'posts (alist-get 'post_stream r))) (first-post (aref posts 0)) (topic-slug (alist-get 'slug r)) (topic-title (alist-get 'title r)) (topic-date (alist-get 'created_at r)) (topic-date-string (format-time-string "%c" (encode-time (parse-time-string topic-date)))) (userid (alist-get 'user_id r)) (participants (alist-get 'participants (alist-get 'details r))) (topic-username (alist-get 'username first-post)) (topic-author-alist (seq-find (lambda (p) (string= (alist-get 'username p) topic-username)) participants)) ; (topic-author (alist-get 'username topic-author-alist)) (topic-flairname (alist-get 'name topic-author-alist)) (topic-author (concat (alist-get 'display_username first-post) " ("(alist-get 'username first-post) ")")) (buf (discoursel-get-create-buffer topic-slug))) (switch-to-buffer buf) (with-current-buffer buf (discoursel-topic-mode) (save-excursion (let ((inhibit-read-only t)) (erase-buffer) (discoursel--insert-post first-post topic-title) (cl-loop for idx from 1 to (1- (length posts)) do (goto-char (point-max)) (insert "\n" (with-temp-buffer (insert "
") (shr-render-region (point-min) (point-max)) (buffer-string))) (discoursel--insert-post (aref posts idx)))))))) ;; (save-excursion ;; (insert ;; (with-temp-buffer ;; (insert (format (propertize "Title: %s\n" 'face 'message-header-name) ;; (propertize topic-title 'face 'message-header-subject))) ;; (insert (format (propertize "Author: %s\n" 'face 'message-header-name) ;; (propertize topic-author 'face 'message-header-to))) ;; (insert (format (propertize "Date: %s\n" 'face 'message-header-name) ;; (propertize topic-date-string 'face 'message-header-other))) ;; (insert "\n") ;; (save-excursion ;; (insert (alist-get 'cooked first-post))) ;; (let ((shr-use-fonts nil) ;; (shr-external-rendering-functions ;; '((pre . discoursel--parse-pre)))) ;; (shr-render-region (point) (point-max))) ;; (buffer-string)))))))) (defun discoursel-open-topic (id) (interactive "nTopic ID: ") (discoursel--open-topic id)) (defun discoursel-get-create-buffer (&optional name) (let* ((bufname (concat "*discoursel" (when name (concat "-" name)) "*")) (buf (get-buffer-create bufname))) (prog1 buf (unless (memq buf discoursel-buffer-list) (add-to-list 'discoursel-buffer-list buf))))) (defun discoursel-kill-buffer (&optional buf) (interactive) (let* ((tmpb (or buf (current-buffer))) (b (get-buffer tmpb))) (unless b (error "Invalid buffer %s" b)) (with-current-buffer b (unless (derived-mode-p 'discoursel-mode) (error "Cannot kill buffer %s: not a discoursel buffer" (buffer-name)))) (kill-buffer b) (setq discoursel-buffer-list (remove b discoursel-buffer-list)))) (defun discoursel-exit () (interactive) (dolist (buf discoursel-buffer-list) (kill-buffer buf)) (setq discoursel-buffer-list nil)) (defvar discoursel-mode-map (let ((map (make-sparse-keymap))) (prog1 map (suppress-keymap map) (define-key map "n" #'next-line) (define-key map "p" #'previous-line) (define-key map (kbd "RET") #'discoursel--open-topic) (define-key map "j" #'discoursel-open-topic) (define-key map "q" #'discoursel-exit) (define-key map "g" #'discoursel-update))) "Keymap for `discoursel-mode'.") (defvar discoursel-topic-mode-map (let ((map (make-sparse-keymap))) (prog1 map (suppress-keymap map) (define-key map "n" #'next-line) (define-key map "p" #'previous-line) (define-key map (kbd "M-n") #'discoursel-topic-next-entry) (define-key map (kbd "M-p") #'discoursel-topic-previous-entry) (define-key map "q" #'discoursel-kill-buffer))) "Keymap for `discoursel-topic-mode'.") (define-derived-mode discoursel-mode special-mode "Discourse client" "Major mode for interacting with Discourse instances. \\{discoursel-mode-map}" (use-local-map discoursel-mode-map)) (define-derived-mode discoursel-topic-mode discoursel-mode "Discoursel topic" "Major mode for interacting with Discourse topics. \\{discoursel-topic-mode-map}" (use-local-map discoursel-topic-mode-map)) (defun discoursel () "Enter discoursel." (interactive) (switch-to-buffer (discoursel-get-create-buffer)) (unless (eq major-mode 'discoursel-mode) (discoursel-mode)) (discoursel-update)) ;; TODO ;; (with-current-buffer (get-buffer-create "test-discourse") ;; (insert (plz 'get "https://discourse.ubuntu.com/raw/39765")) ;; (markdown-mode) ;; (setq buffer-read-only t)) ;; (with-current-buffer (get-buffer-create "test-discourse4") ;; (let* ((ttt (plz 'get "https://discourse.ubuntu.com/latest.json" :as #'json-read)) ;; (topics (alist-get 'topics (alist-get 'topic_list ttt))) ;; (users (alist-get 'users ttt))) ;; (cl-map 'vector ;; (lambda (topic) ;; (let* ((uid (alist-get 'user_id (aref (alist-get 'posters topic) 0))) ;; (user (seq-find (lambda (v) (= (alist-get 'id v) uid)) ;; users))) ;; (insert ;; (propertize (format "%s [%s]\n" (alist-get 'title topic) (alist-get 'name user)) ;; 'discoursel-slug (alist-get 'slug topic))))) ;; topics))) (provide 'discoursel) ;;; discoursel.el ends here