dotfiles/emacs-lisp/org_gtd.org
Magic_RB 07ffaccb58 Support ~org-roam~ recipes based on tempel
Signed-off-by: Magic_RB <magic_rb@redalder.org>
2023-09-16 19:54:34 +02:00

15 KiB

Org GTD

So let me preface this file with a little… preface. When I began this file, it was a few hours after I decided it's time to finally take up GTD and step my game up when it comes to organisation. So this file, along with Org Roam and Org Mode are the culmination of my efforts.

End Goal

What I've very quickly, that GTD is only working properly when at no point, you stop to think "What did I want to/was supposed to do", if that ever happens to you, you're doing GTD wrong. With that in mind and me starting university in.. 5 days.. I want to ensure that forgetting an appointment, assignment, homework and forgetting to call a friend or reply to an email are all things of the past. To that end, I'll from now on, capture everything, be it on my phone or at one of my workstations.

The Phone Thing

What I've learned after crafting my own workflows and using Emacs and Linux for almost 4 years now (WOW!) is that if a workflow isn't reliable, annoyance-free and convenient, I won't succeed in using it long term. Therefore any new workflow I adopt, must fill all those checkboxes or it won't stick. That's why I'm on the look out for handheld computers or linux phones which would enable me to use Emacs conveniently on the go. I've yet to find any which would be pocket sized or cheap enough that I could afford them. One requirement i have except for the Linux thing is that they must have a physical keyboard if I'm to lug around a second device.

For now, I've decided to make due with Emacs installed in Termux with home-manager.

Implementation

So let's start with the entry point to the whole thing. Those would be the org-roam-capture-templates and org-roam-capture-ref-templates. I've gone with more than just what was shown in Get Things Done with Emacs to remove some burden from me when I'm refiling each day. The cognitive overhead created by having to decide on what you're capturing isn't big enough for me to just capture everything into one disorganized heading. At least that's what I think now, we'll see how it goes.

I've split the capturing process into 7 different templates, first we have the resource templates, of which I have 2 currently, one for automatic capturing from a web browser and one for capturing manually from Emacs. Then we have a special event template, which can be quickly used to capture events and have them immediately show up in you agenda. Next up is a special template for capturing Emacs LISP code, that's mostly used when adding new packages and playing with the configuration. Second to last is a catch all email capturing template and lastly a catch all generic capturing template.

So the process for capturing is to trigger the capture with C-c o c and then quickly decide between one of the categories, simple enough hopefully.

  (require 'org-roam-protocol)
  (setq org-roam-capture-ref-templates
        `(("rw" "Web resource" entry
           ,(concat "* ${title} :resource:inbox:\n"
                    ":PROPERTIES:\n"
                    ":CREATED: %U\n"
                    ":ROAM_REFS: ${ref}\n"
                    ":ID: %(org-id-uuid)\n"
                    ":END:\n\n"
                    "${body}")
           :target (file+olp "inbox.org" ("Resources")))
          ("s" "Shopping list" entry
           ,(concat "* ${title} :inbox:\n"
                    ":PROPERTIES:\n"
                    ":CREATED: %U\n"
                    ":END:\n\n"
                    "\n"
                    "${ref}\n\n"
                    "${body}")
           :target (file+olp "inbox.org" ("Shopping list")))))

  (defun org-roam-tempel-recipe ()
    "."
    (setq-local org-roam-node org-roam-capture--node)
    (org-entry-delete nil "ID")
    (tempel-insert
     '(":PROPERTIES:" n
       ":CREATED: " (org-capture-fill-template "%U")
       ":ID: " (org-roam-node-id org-roam-node) n
       ":servings: " p n
       ":prep-time: " (s prep-time) " m" n
       ":cook-time: " (s cook-time) " m" n
       ":ready-in: " (when-let ((prep (and prep-time (string-to-number prep-time)))
                                (cook (and cook-time (string-to-number cook-time))))
                       (number-to-string (+ prep cook))) " m" n
       ":END:" n
       "#+title: " (org-roam-node-title org-roam-node) n
       "#+filetags: :recipe:" n
       "* Ingredients" n
       " - " r n
       "* Directions" n
       " - " n)))

  (defun org-roam-tempel-web-resource ()
    "."
    (setq-local org-roam-node org-roam-capture--node)
    (org-entry-delete nil "ID")
    (tempel-insert
     '(":PROPERTIES:" n
       ":CREATED: " (org-capture-fill-template "%U") n
       ":ID: " (org-roam-node-id org-roam-node) n
       ":ROAM_REFS: " (completing-read \"URL for resource: \" nil nil nil nil nil (or (substring-no-properties (car kill-ring)) nil)) n
       ":END:" n
       "#+title: " (org-roam-node-title org-roam-node) n
       "#+filetags: :resource:" n n)))

  (defun org-roam-tempel-node (standalone)
    "."
    (setq-local org-roam-node org-roam-capture--node)
    (org-entry-delete nil "ID")
    (tempel-insert
     `(,@(unless standalone '("* ${title} :inbox:" n))
       ":PROPERTIES:" n
       ":ID: " (org-roam-node-id org-roam-node) n
       ":CREATED: " (org-capture-fill-template "%U")
       ":END:" n
       "#+setupfile: ~/roam/emacs-lisp/setupfiles/latex-base.org" n
       ,@(when standalone '("#+title: " (org-roam-node-title org-roam-node) n)) n
       )))

  (defun org-roam-tempel-task ()
    "."
    (setq-local org-roam-node org-roam-capture--node)
    (setf (org-roam-node-id org-roam-node) (org-id-uuid))
    (tempel-insert
     `(":PROPERTIES:" n
       ":CREATED: " (org-capture-fill-template "%U")
       ":ID: " (org-roam-node-id org-roam-node) n
       ":Effort: " p n
       ":END:" n
       )))

  (setq org-roam-capture-templates
        `(("i" "Inbox" entry
           "%?"
           :target (file+olp "inbox.org" ("Shopping list"))
           :hook (lambda () (org-roam-tempel-node nil)))

          ("f" "File" plain
           "%?"
           :target (file "${slug}.org")
           :hook (lambda () (org-roam-tempel-node t)))

          ("@" "Inbox [mu4e]" entry
           ,(concat "* Process \"%a\" %? :inbox:\n"
                    ":PROPERTIES:\n"
                    ":CREATED: %U\n"
                    ":ID: %(org-id-uuid)\n"
                    ":END:\n\n"
                    "%?")
           :target (file+olp "inbox.org" ("All")))

          ("t" "Task" entry
           ,(concat "* TODO ${title}\n"
                    "%?")
           :target (file "tasks.org")
           :empty-lines 1
           :hook org-roam-tempel-task)

          ("r" "Resource")

          ("rw" "Web resource" plain
           "%?"
           :target (file "web-${slug}.org")
           :hook org-roam-tempel-web-resource)

          ("rr" "Recipe" plain
           "%?"
           :target (file "recipe-${slug}.org")
           :hook org-roam-tempel-recipe)

          ("E" "Event" entry
           ,(concat "* ${title} :event:inbox:\n"
                    ":PROPERTIES:\n"
                    ":DATE: %(org-time-stamp nil)\n"
                    ":CREATED: %U\n"
                    ":ID: %(org-id-uuid)\n"
                    ":END:\n\n"
                    "%?")
           :target (file+olp "inbox.org" ("Events")))
          ("e" "Emacs Lisp" plain "%?"
           :target (file+head "emacs-lisp/${slug}.org"
                              ,(concat ":PROPERTIES:\n"
                                       ":header-args:emacs-lisp: :comments link :results none\n"
                                       ":END:\n"
                                       "#+title: ${title}\n"
                                       "#+filetags: emacs-load"
                                       "")))))

A special version of org-refile follows. What makes it special is that it first asks you for which Org Roam node you want to refile to and only then refiles. This eases the workflow quite a lot and also makes refiling snappy is it only needs to parse one file not a few thousand.

  ;; Make the refile completing read prompt also list the file itself in case it's empty
  ;; and also not require multiple consecutive selections in case of nested headings.
  (setq org-refile-use-outline-path 'file)
  (setq org-outline-path-complete-in-steps nil)

  (defun org-roam-refile-incremental ()
    (interactive)
    (let* ((node (org-roam-node-read))
           (org-refile-target-verify-function nil)
           (org-refile-targets `((,(org-roam-node-file node) :maxlevel . 9))))
      (org-refile-cache-clear)
      (call-interactively 'org-refile)))

Please ignore this next block, this is some code that took me way too long to figure out and even longer to realize it's already been implemented upstream.

  (element (org-element-at-point))
  (while (not (eq (car element) 'headline))
    (setq element (plist-get (car (cdr element)) :parent)))
  (setq element (car (cdr element)))

  (let ((properties (org-entry-properties
                     (plist-get element :begin)
                     'standard)))
    (message "%s" properties))

  (delete-region
   (plist-get element :begin)
   (plist-get element :end))

  (if (org-roam-node-file node)
      (progn)
    (org-roam-capture-
     :node node
     :templates `()
     :info `()
     :keys ""
     :props '(:finalize )))

This little advice cleans up after the org-roam-promote-entire-buffer function a bit. It leaves the buffer in a state that isn't quite what I want formatting and structure wise, so this just quickly fixes that.

  (defun magic_rb/org-roam-promote-buffer-cleanup ()
    (goto-char 1)
    (org-roam-end-of-meta-data)
    (delete-region (point) (progn (skip-chars-forward " \t") (point)))
    (org-next-visible-heading 1)
    (unless (eq (point) (point-max)) (insert "\n\n"))
    (whitespace-cleanup)
    (org-roam-db-update-file)
    (org-roam-tag-remove '(inbox)))
  (advice-add 'org-roam-promote-entire-buffer :after #'magic_rb/org-roam-promote-buffer-cleanup)

  (defun magic_rb/org-roam-promote-buffer-prepare ()
    (org-with-point-at 1
      (org-next-visible-heading 1)
      ;; (when (and (not (org-roam--buffer-promotable-p))
      ;;        (org-roam-get-keyword "filetags")
      ;;        (not (org-get-tags nil t)))
      ;;   )
      (org-todo "")
      ))
  (advice-add 'org-roam-promote-entire-buffer :before #'magic_rb/org-roam-promote-buffer-prepare)

Next this function actually fixes a bug and cleans up after the ref capture. As of now [2022-08-31] the org-ref-capture protocol has a few minor issues:

  1. you can't tell it not not create a ROAM_REFS property
  2. it always creates the ROAM_REFS property at the root of the Org Roam node it's capturing into so if you're capturing into a heading for later refilling without an ID, it'll create a ROAM_REFS property but at the wrong place
  3. when you capture into a buffer that already has a ROAM_REFS it'll break completely, so we must remove it after it adds it
  (defun magic_rb/clear-roam-refs-if-in-inbox ()
    (with-current-buffer  (org-capture-get :buffer)
      (let* ((target-file (file-name-nondirectory (buffer-file-name))))
        (when (and (org-roam-capture-p)
                   (string-equal target-file "inbox.org"))
          (save-excursion
            (goto-char (point-min))
            (org-entry-delete nil "ROAM_REFS"))))))
  (add-hook 'org-capture-after-finalize-hook #'magic_rb/clear-roam-refs-if-in-inbox)

Agenda

First we define a few functions, which I got from Stack Overflow. They allow you to filter in Org Agenda views with the syntax as described in Matching tags and properties. They're not used currently, but may come in handy so I just keep them here.

  (defun my/org-match-at-point-p (match)
    "Return non-nil if headline at point matches MATCH.
  Here MATCH is a match string of the same format used by
  `org-tags-view'."
    (funcall (cdr (org-make-tags-matcher match))
             (org-get-todo-state)
             (org-get-tags-at)
             (org-reduced-level (org-current-level))))

  (defun my/org-agenda-skip-without-match (match)
    "Skip current headline unless it matches MATCH.

  Return nil if headline containing point matches MATCH (which
  should be a match string of the same format used by
  `org-tags-view').  If headline does not match, return the
  position of the next headline in current buffer.

  Intended for use with `org-agenda-skip-function', where this will
  skip exactly those headlines that do not match."
    (save-excursion
      (unless (org-at-heading-p) (org-back-to-heading))
      (let ((next-headline (save-excursion
                             (or (outline-next-heading) (point-max)))))
        (if (my/org-match-at-point-p match) nil next-headline))))

Now the fun part. I only define one unified agenda view for now. It allows

  (setq org-todo-keywords '((sequence "TODO(t)" "NEXT(n)" "INPROGRESS(i)" "STUCK(s)" "|" "DONE(d)" "CANCELLED(c)"))
        org-use-fast-todo-selection t)

  (setq org-agenda-custom-commands
        '(("g" "Get Things Done (GTD)"
           ((agenda "")
            (org-ql-block '(and (todo "INPROGRESS")
                                (not (deadline)))
                          ((org-ql-block-header "Tasks Started")))
            (org-ql-block '(and (todo "NEXT")
                                (not (deadline)))
                          ((org-ql-block-header "Tasks Planned")))
            (org-ql-block '(and (deadline "21d")
                                (not (or (todo "NEXT") (todo "CANCELLED") (todo "DONE"))))
                          ((org-ql-block-header "Deadlines")))
            (org-ql-block '(and (todo "TODO")
                                (not (deadline)))
                          ((org-ql-block-header "To be Done")))
            (org-ql-block '(and (tags "inbox"))
                          ((org-ql-block-header "Inbox")))
            (org-ql-block '(and (or (todo "CANCELLED") (todo "DONE")) (closed :on today))
                          ((org-ql-block-header "Completed today")))))))

Keybindings

  (defun org-capture-inbox ()
       (interactive)
       (call-interactively 'org-store-link)
       (org-roam-capture nil "i"))

  (defun org-capture-mail ()
    (interactive)
    (call-interactively 'org-store-link)
    (org-roam-capture nil "@"))

  (setq org-agenda-hide-tags-regexp (regexp-opt '("project" "inbox")))

  (general-define-key
   :keymaps 'global
   "C-c i" 'org-capture-inbox)
  (general-define-key
   :keymaps '(mu4e-headers-mode-map mu4e-view-mode-map)
   "C-c i" 'org-capture-mail)

Org QL

  (use-package org-ql
    :straight (org-ql :fetcher github :repo "alphapapa/org-ql"
              :files (:defaults (:exclude "helm-org-ql.el"))))