Somewhat daunted by the prospect of categorizing more than a hundred sketches and blog posts for my monthly review, I spent some time figuring out how to create the digital equivalent of sorting index cards into various piles.
In fact, wouldn’t it be super-cool if the items could automatically guess which category they should probably go in, prompting me only if it wasn’t clear?
I wanted to write a function that could take a list structured like this:
- Keyword A
- Previous links
- Keyword B
- Previous links
- Link 1 with Keyword A
- Link 2 with Keyword B
- Link 3 with Keyword A
- Link 4
It should file Link 1 and 3 under Keyword A, Link 2 under Keyword B, and prompt me for the category for Link 4. At that prompt, I should be able to select Keyword A or Keyword B, or specify a new category.
Inspired by John Kitchin’s recent post on defining a Helm source, I wanted to get it to work with Helm.
First step: I needed to figure out the structure of the list, maybe including a sample from the category to make it clearer what’s included.
org-list.el seemed to have useful functions for this.
org-list-struct gave me the structure of the current list. Let’s say that a category is anything whose text does not match
(defun sacha/org-get-list-categories () "Return a list of (category indent matching-regexp sample). List categories are items that don't contain links." (let ((list (org-list-struct)) last-category results) (save-excursion (mapc (lambda (x) (goto-char (car x)) (let ((current-item (buffer-substring-no-properties (+ (point) (elt x 1) (length (elt x 2))) (line-end-position)))) (if (string-match org-bracket-link-regexp (buffer-substring-no-properties (point) (line-end-position))) ;; Link - update the last category (when last-category (if (< (elt x 1) (elt last-category 1)) (setq results (cons (append last-category (list (match-string-no-properties 3 (buffer-substring-no-properties (point) (line-end-position))))) (cdr results)))) (setq last-category nil)) ;; Category (setq results (cons (setq last-category (list current-item (elt x 1) (concat "^" (make-string (elt x 1) ?\ ) (regexp-quote (concat (elt x 2) current-item)) "$"))) results))))) list)) results))
The next step was to write a function that guessed the list category based on the item text, and moved the item there.
(defvar sacha/helm-org-list-candidates nil) (defun sacha/helm-org-list-categories-init-candidates () "Return a list of categories from this list in a form ready for Helm." (setq sacha/helm-org-list-candidates (mapcar (lambda (x) (cons (if (elt x 3) (format "%s - %s" (car x) (elt x 3)) (car x)) x)) (sacha/org-get-list-categories)))) (defun sacha/org-move-current-item-to-category (category) (when category (let* ((beg (line-beginning-position)) (end (line-end-position)) (string (buffer-substring-no-properties beg end))) (save-excursion (when (re-search-backward (elt category 2) nil t) (delete-region beg (min (1+ end) (point-max))) (forward-line 1) (insert (make-string (+ 2 (elt category 1)) ?\ ) string "\n")))) t)) (defun sacha/org-guess-list-category (&optional categories) (interactive) (require 'cl-lib) (unless categories (setq categories (sacha/helm-org-list-categories-init-candidates))) (let* ((beg (line-beginning-position)) (end (line-end-position)) (string (buffer-substring-no-properties beg end)) (found (cl-member string categories :test (lambda (string cat-entry) (string-match (regexp-quote (downcase (car cat-entry))) string))))) (when (car found) (sacha/org-move-current-item-to-category (cdr (car found))) t)))
After that, I wrote a function that used Helm to prompt me for a category in case it couldn’t guess the category. It took me a while to figure out that I needed to use
:init instead of
:candidates because I wanted to read information from the buffer before Helm kicked in.
(setq sacha/helm-org-list-category-source (helm-build-sync-source "Non-link categories in the current list" :init 'sacha/helm-org-list-categories-init-candidates :candidates 'sacha/helm-org-list-candidates :action 'sacha/org-move-current-item-to-category :fuzzy-match t)) (defun sacha/org-guess-uncategorized () (interactive) (sacha/helm-org-list-categories-init-candidates) (let (done) (while (not done) (save-excursion (unless (sacha/org-guess-list-category sacha/helm-org-list-candidates) (unless (helm :sources '(sacha/helm-org-list-category-source sacha/helm-org-list-category-create-source)) (setq done t)))) (unless done (setq done (not (looking-at "^[-+] \\[")))))))
:action above refers to this function, which creates a category if it doesn’t exist yet.
(setq sacha/helm-org-list-category-create-source (helm-build-dummy-source "Create category" :action (helm-make-actions "Create category" (lambda (candidate) (save-excursion (let* ((beg (line-beginning-position)) (end (line-end-position)) (string (buffer-substring beg end))) (delete-region beg (min (1+ end) (point-max))) (org-beginning-of-item-list) (insert "- " candidate "\n " string "\n"))) (sacha/helm-org-list-categories-init-candidates)))))
I’m new to fiddling with Helm, so this implementation is not the best it could be. But it’s nifty and it works the way I want it to, hooray! Now I can generate a list of blog posts and unblogged sketches, categorize them quickly, and then tweak the categorizations afterwards.
My next step for learning more about Helm sources is probably to write a Helm command that creates a montage of selected images. John Kitchin has a post about handling multiple selection in Helm, so I just need to combine that with my code for using Imagemagick to create a montage of images. Whee!