Created: February 7, 2019

Last modified: February 5, 2023

Creating Lists of Tagged Posts using Emacs Lisp

One of the features I want for this blog is the ability for “project” type posts which are persistently updated to have links to all historic posts which pertain to the evolution of that project over time. There isn’t built in support for post tagging in projects exported with org-publish so this was a perfect time for me to practice my Emacs lisp (lisp) coding.

I first looked over the implementation of tagging posts in org-static-blog which is a very simple lisp static blog generator. Here are the relevant functions:

(defcustom org-static-blog-posts-directory "~/Documents/website/blog/"
  "Directory where published ORG files are stored.
When publishing, posts are rendered as HTML, and included in the
index, archive, tags, and RSS feed."
:group 'org-static-blog)

(defun org-static-blog-get-post-filenames ()
  "Returns a list of all posts."
  (directory-files
   org-static-blog-posts-directory t ".*\\.org$" nil))

(defun org-static-blog-get-title (post-filename)
  "Extract the `#+title:` from POST-FILENAME."
  (let ((case-fold-search t))
    (with-temp-buffer
      (insert-file-contents post-filename)
      (goto-char (point-min))
      (search-forward-regexp "^\\#\\+title:[ ]*\\(.+\\)$")
      (match-string 1))))

(defun org-static-blog-get-tags (post-filename)
  "Extract the `#+filetags:` from POST-FILENAME as list of strings."
  (let ((case-fold-search t))
    (with-temp-buffer
      (insert-file-contents post-filename)
      (goto-char (point-min))
      (if (search-forward-regexp "^\\#\\+filetags:[ ]*\\(.+\\)$" nil t)
          (split-string (match-string 1))))))

(defun org-static-blog-get-tag-tree ()
  "Return an association list of tags to filenames.
e.g. `(('foo' 'file1.org' 'file2.org') ('bar' 'file2.org'))`"
  (let ((tag-tree '()))
    (dolist (post-filename (org-static-blog-get-post-filenames))
      (let ((tags (org-static-blog-get-tags post-filename)))
        (dolist (tag tags)
          (if (assoc-string tag tag-tree t)
              (push post-filename (cdr (assoc-string tag tag-tree t)))
            (push (cons tag (list post-filename)) tag-tree)))))
    tag-tree))

The first thing to notice is that this org-static-blog uses regexp to extract information for the keywords at the start of an org file (we’re interested in the #+title: and #+date: and #+filetags: keywords). While this certainly works, I think it makes more sense and is perhaps more instructive and robust to use let org parse the buffer with org-element-parse-buffer into the Abstract Syntax Tree (ASST) representing the org buffer and retrieve our keywords by mapping and reducing across this AST.

I used this StackExchange answer as the basis for creating the following function:

(defun my-org-get-keyword (post-filename keyword)
  "Extract the value of `#+keyword:` from post-filename."
  (let ((case-fold-search t))
    (with-temp-buffer
      (insert-file-contents post-filename)
      (org-element-property
       :value (car (org-element-map (org-element-parse-buffer) 'keyword
                     (lambda (el)
                       (when (string-match keyword (org-element-property :key el))
                         el))))))))

Which wen we call it with an org file asking for the title we get:

(my-org-get-keyword "~/Documents/website/blog/2019-02-06-creating-tag-post-lists.org" "title")
Creating Lists of Tagged Posts using Emacs Lisp

I then combined my-org-get-keyword with a modified version of the org-static-blog-get-tag-tree function to get the tag tree for my blog:

(setq my-blog-posts-directory "~/Documents/website/blog/")

(defun my-blog-get-post-filenames ()
  "Returns a list of all posts."
  (reverse
   (seq-filter (lambda (el) (not (string-match ".*index\\.org$" el)))
               (directory-files my-website-blog-dir t ".*\\.org$" nil))))

(defun my-blog-get-tag-tree ()
  "Return an association list of tags to filenames.
e.g. `(('foo' 'file1.org' 'file2.org') ('bar' 'file2.org'))`"
  (let ((tag-tree '()))
    (dolist (post-filename (my-blog-get-post-filenames))
      (let* ((tags-str (my-org-get-keyword post-filename "filetags"))
             (tags (if tags-str (split-string tags-str))))
        (dolist (tag tags)
          (if (assoc-string tag tag-tree t)
              (push post-filename (cdr (assoc-string tag tag-tree t)))
            (push (cons tag (list post-filename)) tag-tree)))))
    tag-tree))

Calling my-blog-get-tree gives us:

(my-blog-get-tag-tree)
elisp 2019-02-06-creating-tag-post-lists.org      
emacs 2019-02-06-creating-tag-post-lists.org 2019-02-05-starting-blogging.org 2019-02-05-ox-slimhtml.org  
css 2019-01-01-cuss.org      
blog 2019-02-06-creating-tag-post-lists.org 2019-02-05-starting-blogging.org 2019-02-05-ox-slimhtml.org 2019-01-01-css.org
org-mode 2019-02-06-creating-tag-post-lists.org 2019-02-05-starting-blogging.org 2019-02-05-ox-slimhtml.org 2019-01-01-css.org

Now I just need to filter this tag-tree for only the tag I want:

(defun my-blog-filenames-for-tag (tag)
  (reverse (cdr (seq-find (lambda (el) (string= (car el) tag))
                         (my-blog-get-tag-tree)))))

Which gives us

(my-blog-filenames-for-tag "emacs")
2019-02-06-creating-tag-post-lists.org 2019-02-05-starting-blogging.org 2019-02-05-ox-slimhtml.org

Finally I need a function to format this list of post filenames into an org list of formatted org links to those files with their date and title taking care to use relative links (otherwise on export, a link from an org file in one directory to an org file in another directory will be broken).

(defun my-blog-posts-to-list (post-filenames)
  (seq-reduce
   (lambda (acc el) (concat acc "\n- " el))
   (seq-map (lambda (el)
              (org-make-link-string
               (concat "file:" (file-relative-name el))
               (concat "/"  (my-org-get-keyword el "date")
                       "/ " (my-org-get-keyword el "title"))))
            post-filenames)
   ""))

And voilĂ ! We have our formatted list of links to all the posts tagged “blog”:

(my-blog-posts-to-list (my-blog-filenames-for-tag "blog"))

Update: There’s a bug with this method! See part 2 for the fix.

Backlinks: