Skip to content

Instantly share code, notes, and snippets.

@d12frosted
Last active September 26, 2025 12:06
Show Gist options
  • Save d12frosted/a60e8ccb9aceba031af243dff0d19b2e to your computer and use it in GitHub Desktop.
Save d12frosted/a60e8ccb9aceba031af243dff0d19b2e to your computer and use it in GitHub Desktop.
(defun vulpea-project-p ()
"Return non-nil if current buffer has any todo entry.
TODO entries marked as done are ignored, meaning the this
function returns nil if current buffer contains only completed
tasks."
(seq-find ; (3)
(lambda (type)
(eq type 'todo))
(org-element-map ; (2)
(org-element-parse-buffer 'headline) ; (1)
'headline
(lambda (h)
(org-element-property :todo-type h)))))
(defun vulpea-project-update-tag ()
"Update PROJECT tag in the current buffer."
(when (and (not (active-minibuffer-window))
(vulpea-buffer-p))
(save-excursion
(goto-char (point-min))
(let* ((tags (vulpea-buffer-tags-get))
(original-tags tags))
(if (vulpea-project-p)
(setq tags (cons "project" tags))
(setq tags (remove "project" tags)))
;; cleanup duplicates
(setq tags (seq-uniq tags))
;; update tags if changed
(when (or (seq-difference tags original-tags)
(seq-difference original-tags tags))
(apply #'vulpea-buffer-tags-set tags))))))
(defun vulpea-buffer-p ()
"Return non-nil if the currently visited buffer is a note."
(and buffer-file-name
(eq major-mode 'org-mode)
(string-suffix-p "org" buffer-file-name)
(string-prefix-p
(expand-file-name (file-name-as-directory vulpea-directory))
(file-name-directory buffer-file-name))))
(defun vulpea-project-files ()
"Return a list of note files containing 'project' tag." ;
(seq-uniq
(seq-map
#'car
(org-roam-db-query
[:select [nodes:file]
:from tags
:left-join nodes
:on (= tags:node-id nodes:id)
:where (like tag (quote "%\"project\"%"))]))))
(defun vulpea-agenda-files-update (&rest _)
"Update the value of `org-agenda-files'."
(setq org-agenda-files (vulpea-project-files)))
(add-hook 'find-file-hook #'vulpea-project-update-tag)
(add-hook 'before-save-hook #'vulpea-project-update-tag)
(advice-add 'org-agenda :before #'vulpea-agenda-files-update)
(advice-add 'org-todo-list :before #'vulpea-agenda-files-update)
;; functions borrowed from `vulpea' library
;; https://github.com/d12frosted/vulpea/blob/6a735c34f1f64e1f70da77989e9ce8da7864e5ff/vulpea-buffer.el
(defun vulpea-buffer-tags-get ()
"Return filetags value in current buffer."
(vulpea-buffer-prop-get-list "filetags" "[ :]"))
(defun vulpea-buffer-tags-set (&rest tags)
"Set TAGS in current buffer.
If filetags value is already set, replace it."
(if tags
(vulpea-buffer-prop-set
"filetags" (concat ":" (string-join tags ":") ":"))
(vulpea-buffer-prop-remove "filetags")))
(defun vulpea-buffer-tags-add (tag)
"Add a TAG to filetags in current buffer."
(let* ((tags (vulpea-buffer-tags-get))
(tags (append tags (list tag))))
(apply #'vulpea-buffer-tags-set tags)))
(defun vulpea-buffer-tags-remove (tag)
"Remove a TAG from filetags in current buffer."
(let* ((tags (vulpea-buffer-tags-get))
(tags (delete tag tags)))
(apply #'vulpea-buffer-tags-set tags)))
(defun vulpea-buffer-prop-set (name value)
"Set a file property called NAME to VALUE in buffer file.
If the property is already set, replace its value."
(setq name (downcase name))
(org-with-point-at 1
(let ((case-fold-search t))
(if (re-search-forward (concat "^#\\+" name ":\\(.*\\)")
(point-max) t)
(replace-match (concat "#+" name ": " value) 'fixedcase)
(while (and (not (eobp))
(looking-at "^[#:]"))
(if (save-excursion (end-of-line) (eobp))
(progn
(end-of-line)
(insert "\n"))
(forward-line)
(beginning-of-line)))
(insert "#+" name ": " value "\n")))))
(defun vulpea-buffer-prop-set-list (name values &optional separators)
"Set a file property called NAME to VALUES in current buffer.
VALUES are quoted and combined into single string using
`combine-and-quote-strings'.
If SEPARATORS is non-nil, it should be a regular expression
matching text that separates, but is not part of, the substrings.
If nil it defaults to `split-string-default-separators', normally
\"[ \f\t\n\r\v]+\", and OMIT-NULLS is forced to t.
If the property is already set, replace its value."
(vulpea-buffer-prop-set
name (combine-and-quote-strings values separators)))
(defun vulpea-buffer-prop-get (name)
"Get a buffer property called NAME as a string."
(org-with-point-at 1
(when (re-search-forward (concat "^#\\+" name ": \\(.*\\)")
(point-max) t)
(buffer-substring-no-properties
(match-beginning 1)
(match-end 1)))))
(defun vulpea-buffer-prop-get-list (name &optional separators)
"Get a buffer property NAME as a list using SEPARATORS.
If SEPARATORS is non-nil, it should be a regular expression
matching text that separates, but is not part of, the substrings.
If nil it defaults to `split-string-default-separators', normally
\"[ \f\t\n\r\v]+\", and OMIT-NULLS is forced to t."
(let ((value (vulpea-buffer-prop-get name)))
(when (and value (not (string-empty-p value)))
(split-string-and-unquote value separators))))
(defun vulpea-buffer-prop-remove (name)
"Remove a buffer property called NAME."
(org-with-point-at 1
(when (re-search-forward (concat "\\(^#\\+" name ":.*\n?\\)")
(point-max) t)
(replace-match ""))))
@JonathanReeve
Copy link

I used the above code for years, tweaking it every so often to suit my use cases, and adapting some of the code from vulpea, to reduce dependencies. However, I've recently moved to org-mem, which now allows me to simply write this (copied from org-mem's README):

 (defun my-set-agenda-files (&rest _)
  (setq org-agenda-files
        (cl-loop
         for file in (org-mem-all-files)
         unless (string-search "archive" file)
         when (seq-find (lambda (entry)
                          (or (org-mem-entry-active-timestamps entry)
                              (org-mem-entry-todo-state entry)
                              (org-mem-entry-scheduled entry)
                              (org-mem-entry-deadline entry)))
                        (org-mem-entries-in file))
         collect file)))
(add-hook 'org-mem-post-full-scan-functions #'my-set-agenda-files)

The command is virtually instantaneous, across my thousands of org files. Hope this helps others that come across this.

@DavidPfl
Copy link

I came here from the blog post Task management with org-roam Vol. 5: Dynamic and fast agenda and wanted to migrate existing todos with the provided script.

I encountered the error Debugger entered--Lisp error: (void-variable vulpea-directory).
I assume thats because in the gist above, the method vulpea-buffer-p () uses vulpea-directory, which is not defined anywhere. In the blog post, the same method uses org-roam-directory, which is defined when using org-roam.

I changed vulpea-directory back to org-roam-directory, the error disappeared and existing TODOs were migrated as expected.

Should I have defined vulpea-directory anywhere? I use Doom Emacs and installed vulpea as described in the README.

@d12frosted
Copy link
Author

@DavidPfl
oh, I never defined it; yeah, you can just use (defvar vulpea-directory org-roam-directory) or replace it with org-roam-directory completely - up to you :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment