Making a numpad-based hydra for categorizing Org list items

I like to categorize links for Emacs News so that it’s not an overwhelmingly long wall of text. After I’ve deleted duplicate links, there are around a hundred links left to categorize. I used to use Helm and some custom code to simplify moving Org list items into different categories in the same list. Then I can type “org” to move something to the Org Mode category and “dev” to move something to the Emacs development category.

When I don’t have a lot of computer time, I usually do this categorization by SSHing into my server from my phone. It’s hard to type on my phone, though. I thought a numpad-based Hydra might be better for quick entry, like a phone system. I wanted to be able to use the numeric keypad to sort items into the most common categories, with a few shortcuts for making it easier to organize. Here’s a list of shortcuts:

0-9 Select options from the list, with 0 for other.
\/ Opens the URL in a web browser
, Selects a category by name, creating as needed
\* Shows the URL in the messages buffer, and toggles it
Deletes the item
. Quits

Here’s what the Helm version looked like on the left, and here’s the new numpad-powered one on the right. I liked how fewer buttons made it easier to hit the right one when I’m sorting on my phone. I can add new categories with completion. Because I assigned numbers to specific categories instead of having them automatically calculated based on the headings in the list, it was easy to get into the rhythm of tapping 6 for Org, 7 for coding, and so on.

Comparison of Helm and Hydra approaches

Here’s the code that makes it happen. I experimented with dynamically defining a hydra using eval and defmacro so that I could more easily define the menu in a variable. It seems to work fine so far.

(defvar my/org-categorize-emacs-news-menu 
  '(("0" . "Other")
    ("1" . "Emacs Lisp")
    ("2" . "Emacs development")
    ("3" . "Emacs configuration")
    ("4" . "Appearance")
    ("5" . "Navigation")
    ("6" . "Org Mode")
    ("7" . "Coding")
    ("8" . "Community")
    ("9" . "Spacemacs")))

(defun my/org-move-current-item-to-category (category)
  "Move current list item under CATEGORY earlier in the list.
CATEGORY can be a string or a list of the form (text indent regexp).
Point should be on the next line to process, even if a new category
has been inserted."
  (interactive "MCategory: ")
  (when category
    (let* ((beg (line-beginning-position))
           (end (line-end-position))
           (string (org-trim (buffer-substring-no-properties beg end)))
           (category-text (if (stringp category) category (elt category 0)))
           (category-indent (if (stringp category) 2 (+ 2 (elt category 1))))
           (category-regexp (if (stringp category) category (elt category 2)))
           (pos (point))
           s)
      (delete-region beg (min (1+ end) (point-max)))
      (unless (string= category-text "x")
        (if (re-search-backward category-regexp nil t)
            (forward-line 1)
          (setq s (concat "- " category-text "\n"))
          (insert s)
          (setq pos (+ (length s) pos)))
        (insert (make-string category-indent ?\ )
                string "\n")
        (goto-char (+ pos (length string) category-indent 1))
        (recenter)))))

(eval 
 `(defhydra my/org-categorize-emacs-news (global-map "C-c e")
    ,@(mapcar
       (lambda (x)
         `(,(car x)
           (lambda () (interactive) (my/org-move-current-item-to-category ,(concat (cdr x) ":")))
           ,(cdr x)))
       my/org-categorize-emacs-news-menu)
    (","
     (lambda () (interactive)
       (my/org-move-current-item-to-category 
        (completing-read (match-string 2) (my/org-get-list-categories))))
     "By string")
    ("/" (lambda () (interactive)
           (save-excursion
             (re-search-forward org-link-bracket-re)
             (backward-char)
             (org-open-at-point)))
     "Open")
    ("*"
     (lambda () (interactive)
       (if (string= (buffer-name) "*Messages*")
           (bury-buffer)
         (save-excursion
           (re-search-forward org-link-bracket-re)
           (message (match-string 1)))
         (switch-to-buffer "*Messages*")))
     "Show URL")
    ("-" kill-whole-line "Kill")
    ("." nil "Done")))

Let’s see how it holds up next week!