Project Dispatch Menu with Org Mode Metadata, Denote, and Transient

Creating a Means of Quickly Navigating Amongst a Projects Important “Pages”

At Software Services by Scientist.com I work on several different projects. Ideally, one project at a time, but within a week I might move between two or three. Note taking procedures help me re-orient to a new project.

I spent some time reflecting on the common jumping off points for a project:

Local Source Code
the local repository of code for the project; thus far each project has one repository.
Remote Source Code
the remote repository where I interact with issues and pull requests.
Remote Project Board
the page that has the current project tasks and their swimlanes.
Agenda/Timesheet
the place where I track my local time and write notes.
Local Project Note
the place where I track important links or information regarding the project.

When I’m working on the project, I’m often navigating between those five points. Since I work in Emacs I figured I’d write up some code.

First, I thought about the data. Where should I store this information? Looking at the above list, the best candidate was the Local Project Note; a note written in Org-Mode and I use Denote to help me manage this kind of note.

For each project document I added the following keywords (e.g. those that can be found by the org-collect-keywords function):

#+PROJECT_NAME:
By convention, this is the short-name that I use for my timesheet and task management. (See Org Mode Capture Templates and Time Tracking for more details.)
#+PROJECT_PATH_TO_CODE:
The file path to the code on my machine.
#+PROJECT_PATH_TO_REMOTE:
The URL of the remote repository.
#+PROJECT_PATH_TO_BOARD:
The URL of the remote project board.

An astute reader will realize that I’ve done my best to normalize the keywords. I envision that instead of a hard-coded set of “jump to” functions I could instead select the project and then iterate through the keywords keys that start with PROJECT_PATH_TO and create a selector for that. But I digress.

The Helper Functions

I wanted a common mechanism for selecting the project. I wrote the following function:

(cl-defun jf/project/list-projects (&key (project ".+")
					 (directory org-directory))
  "Return a list of `cons' that match the given PROJECT.

The `car' of the `cons' is the project (e.g. \"Take on Rules\").
The `cdr' is the fully qualified path to that projects notes file.

The DIRECTORY defaults to `org-directory' but you can specify otherwise."
  (mapcar (lambda (line)
	    (let* ((slugs (s-split ":" line))
'		   (proj (s-trim (car (cdr slugs))))
		   (filename (file-truename (s-trim (car slugs)))))
	      (cons proj filename)))
	  (split-string-and-unquote
	   (shell-command-to-string
	    (concat
	     "rg \"^#\\+PROJECT_NAME: +(" project ") *$\" " directory
	     " --only-matching --no-ignore-vcs --with-filename -r '$1' "
	     "| tr '\n' '@'"))
	   "@")))

It searches through my org-directory for the given project; by default that project is a fragment of a regular expression. That regular expression is “any and all characters.” I can use the above function as a parameter for completing-read.

I also want to set my default project. For this, I used Transient’s transient-define-suffix function. Below is jf/project/transient-current-project, a function I use to manage and display the jf/project/current-project variable.

(defvar jf/project/current-project
  nil
  "The current contextual project.")

(transient-define-suffix jf/project/transient-current-project (project)
  "Select FILES to use as source for work desk."
  :description '(lambda ()
		  (concat
		   "Current Project:"
		   (propertize
		    (format "%s" jf/project/current-project)
		    'face 'transient-argument)))
  (interactive (list (completing-read "Project: "
				      (jf/project/lis't-projects))))
  (setq jf/project/current-project project))

I also recognized that I might want to auto-magically select a project. So I wrote up the basic jf/project/find-dwim:

(defun jf/project/find-dwim ()
  "Find the current project."
  (completing-read "Project: " (jf/project/list-projects)))

The above function could look at the current clock in Org Mode and determine the associated project. Or, if I’m in a repository look to see what project it is associated with. Or whatever other mechanisms. For now, it prompts for me to pick a project.

The Interactive Functions

With the above “plumbing” I wrote five functions:

  • jf/project/jump-to-agenda
  • jf/project/jump-to-board
  • jf/project/jump-to-code
  • jf/project/jump-to-notes
  • jf/project/jump-to-remote

The jf/project/jump-to-agenda function is a bit different, it tries to jump to today’s agenda item for the project.

(cl-defun jf/project/jump-to-agenda (&optional project
				     &key
				     (tag "project")
				     (within_headline
				      (format-time-string "%Y-%m-%d %A")))
  "Jump to the agenda for the given PROJECT."
  (interactive)
  (let ((the-project (or project (jf/project/find-dwim))))
    (with-current-buffer (find-file jf/pri
mary-agenda-filename-for-machine)
      (let ((start (org-element-map (org-element-parse-buffer)
		       'headline
		     ;; Finds the begin position of:
		     ;; - a level 4 headline
		     ;; - that is tagged as a :project:
		     ;; - is titled as the given project
		     ;; - and is within the given headline
		     (lambda (hl)
		       (and (=(org-element-property :level hl) 4)
			    ;; I can't use the :title attribute as it
			    ;; is a more complicated structure; this
			    ;; gets me the raw string.
			    (string= the-project
				     (plist-get (cadr hl) :raw-value))
			    (member tag
				    (org-element-property :tags hl))
			    ;; The element must have an ancestor with
			    ;; a headline of today
			    (string= within_headline
				     (plist-get
				      ;; I want the raw title, no
				      ;; styling nor tags
				      (cadr
				       (car
					(org-element-lineage hl)))
				      :raw-value))
			    (org-element-property :begin hl)))
		     nil t)))
	(goto-char start)
	(pulsar-pulse-line)))))

The jf/project/jump-to-board function assumes a remote URL.

(cl-defun jf/project/jump-to-board (&optional
				    project
				    &key
				    (keyword "PROJECT_PATH_TO_BOARD"))
  "Jump to the given PROJECT's project board."
  (interactive)
  (let* ((the-project (or project (jf/project/find-dwim)))
	 (filename (cdar (jf/project/list-projects :project the-project))))
    (with-current-buffer (find-file-noselect filename)

      (let ((url (cadar (org-collect-keywords (list keyword)))))
	(eww-browse-with-external-browser url)))))

The jf/project/jump-to-board function assumes a directory on my local machine. The code is similar to the jf/project/jump-to-board.

(cl-defun jf/project/jump-to-code (&optional
				   project
				   &key
				   (keyword "PROJECT_PATH_TO_CODE"))
    "Jump to the given PROJECT's source code."
    (interactive)
    (let* ((the-project (or project (jf/project/find-dwim)))
           (filename (cdar (jf/project/list-projects :project the-project))))
      (with-current-buffer (find-file-noselect filename)
        (let ((filename (file-truename (cadar
					(org-collect-keywords
					 (list keyword))))))
          (if (f-dir-p filename)
              (dired filename)
            (find-file filename))))))

The jf/project/jump-to-notes prompts for the project and then finds the filename.

(cl-defun jf/project/jump-to-notes (&optional project)
  "Jump to the given PROJECT's notes file.

Determine the PROJECT by querying `jf/project/list-projects'."
  (interactive)
  (let* ((the-project (or project (jf/project/find-dwim)))
	 (filename (cdar (jf/project/list-projects :project the-project))))
    (find-file filename)))

Akin to the jf/project/jump-to-board, the jf/project/jump-to-remote opens a remote URL.

(cl-defun jf/project/jump-to-remote (&optional
				     project
				     &key
				     (keyword "PROJECT_PATH_TO_REMOTE"))
  "Jump to the given PROJECT's remote."
  (interactive)
  (let* ((the-project (or project (jf/project/find-dwim)))
	 (filename (cdar (jf/project/list-projects :project the-project))))
    (with-current-buffer (find-file-noselect filename)
      (let ((url (cadar (org-collect-keywords (list keyword)))))
	(eww-browse-with-external-browser url)))))

The Menu

Using Transient I define a menu for my projects. Lower case is for dispatching to the current project. Upper case prompts for the project then dispatches.

(transient-define-prefix jf/project/menu ()
  "My Project menu."
  ["Projects"
   ["Current project"
    ("a" "Agenda…" (lambda () (interactive)
		     (jf/project/jump-to-agenda jf/project/current-project)))
    ("b" "Board…" (lambda () (interactive)
		    (jf/project/jump-to-board jf/project/current-project)))
    ("c" "Code…" (lambda () (interactive)
		   (jf/project/jump-to-code jf/project/current-project)))
    ("n" "Notes…" (lambda () (interactive)
		    (jf/project/jump-to-notes jf/project/current-project)))
    ("r" "Remote…" (lambda () (interactive)
		     (jf/project/jump-to-remote jf/project/current-project)))
    ("." jf/project/transient-current-project :transient t)]
 '  ["Other projects"
    ("A" "Agenda…" jf/project/jump-to-agenda)
    ("B" "Board…" jf/project/jump-to-board)
    ("C" "Code…" jf/project/jump-to-code)
    ("N" "Notes…" jf/project/jump-to-notes)
    ("R" "Notes…" jf/project/jump-to-remote)]
   ])

Conclusion

During my day I spend a lot of time writing and reading; and for large chunks of time those are all related to a single project. Each project has predictable “places” where I will read and write.

The above functions help me both document the relationship of those predictable “places” and automate my navigation to those different tools. In all things Emacs remains my homebase; it is where I can go to re-orient.

My jf-project.el document has the above code and any further updates, bug fixes, etc to the above now static code.