bajsicki.com/content/blog/elisp-neat-tree.md
2025-06-27 12:20:35 +02:00

3.7 KiB

+++ title = "Neat trees from org-ql to org-mode" lastmod = 2025-06-27T12:20:28+02:00 tags = ["orgmode", "elisp"] categories = ["tech", "emacs"] draft = false meta = true type = "list" [menu] [menu.posts] weight = 3001 identifier = "neat-trees-from-org-ql-to-org-mode" +++

Over the past few weeks I've been working on a rather large inventory of items stored and held in org-mode.

For the purposes of this example, let's use the current buffer, bajsicki.com.org

I started using org-ql to grab the data I need, in the form of:

(let ((org-ql-cache (make-hash-table)))
(org-ql-select "bajsicki.com.org"
  '(tags-local "@emacs")
  :action (lambda () (org-get-outline-path t))))

RESULTS:

(("Blog" "Tech" "Neat trees from org-ql to org-mode") ("Blog" "Tech" "I really, really like Emacs") ("Blog" "Tech" "A new look: ox-tufte") ("Blog" "Tech" "Moving to Hugo") ("Blog" "Tech" "Some improvements for my ox-hugo set-up"))

So, this is neat. But notice how it's a list of lists, and there's a lot of repetition.

Suppose you wanted to see this in a more pleasant form, maybe even one that you can interact with in org-mode directly.

To that end, I ended up writing two functions.

  1. Turn the list of lists we get from org-ql into a tree.
(defun phil/make-tree-from-nested-lists (lists)
  (if (not lists)
      nil
    (let ((grouped-lists (seq-group-by #'car lists)))
      (mapcar (lambda (group)
                (let ((key (car group))
                      (sublists (mapcar #'cdr (cdr group))))
                  (cons key (if (not (every #'null sublists))
                                (phil/make-tree-from-nested-lists sublists)
                              sublists))))
              grouped-lists))))
  1. Format and output that tree into org-mode.
(defun phil/org-list-from-tree (tree &optional indent)
  (let ((indent (or indent "")))
    (mapconcat (lambda (item)
                 (if (consp item)
                     (format "%s- %s\n%s" indent (car item)
                             (phil/org-list-from-tree (cdr item) (concat indent "  ")))
		   (when item
		     (format "%s- %s\n" indent item))))
               tree "")))

Example:

(let ((tree (phil/make-tree-from-nested-lists
	     (let ((org-ql-cache (make-hash-table)))
	       (org-ql-select (get-buffer "bajsicki.com.org") ;;the file I write my blog in
		 '(and (tags-local "@emacs"))
		 :action (lambda ()
			   (-snoc ;;this gets the tag list as well, on top of the heading text itself
			    (org-get-outline-path)
			    (replace-regexp-in-string
			     "^*+\s" "" ;; remove initial asterisks, since we're already indenting
			     (buffer-substring-no-properties
			      (line-beginning-position)
			      (line-end-position))))))))))

  (phil/org-list-from-tree tree))
RESULTS:
- Blog
  - Tech
    - I really, really like Emacs :@emacs:orgmode:@tech:
    - A new look: ox-tufte :@emacs:orgmode:web:css:tufte:@tech:
    - Moving to Hugo :hugo:web:orgmode:css:tufte:@emacs:
    - Some improvements for my ox-hugo set-up :hugo:web:orgmode:@emacs:

Pretty neat.

Now, this isn't ideal or even close to good. Here's known issues:

  1. It's jank. I'm sure there's a cleaner way of doing this.
  2. It gets tags. If you don't want them, replace the entire dash.el -snoc form with just (org-get-outline-path t).
  3. Sometimes nil would slip in, and so I'm just removing all nils to start with. This may cause unintended issues. Possibly.
  4. This hasn't been extensively tested, and I only tested it with the tags-local org-ql predicate.

So yeah. There. I may end up wrapping these in a function of some sort, but for the time being this is entirely sufficient for my purposes. We'll see.