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.
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.