A bunch of functions and views to allow org-contacts to serve as the basis for a lightweight, text-based CRM.
Basic workflow:
- Get your contacts into org-contacts format. Traditionally this is done with
org-vcard
, but that didn’t work for me so I made a script.[fn:1] - Add a
:CONTACTED:
property to the cards. It takes a date (not an org-date, just an ISO-8601YYYY-mm-dd
date). The script adds one. - Tag contacts. The custom views depend on the tags
:close:
,:fam:
, and:network:
:close:
Close friends and contacts you want to be reminded to reach out to frequently.:fam:
Same, just family members in that circle.:network:
Professional contacts or other people you may be more transactional with.
- Update
:CONTACTED:
on any of your contacts you can remember recent contact with, just to keep initial agenda views a little cleaner. There are two functions below to help with this:org-set-contacted-today
, which is non-interactive and does what it suggests, andorg-set-contacted-date
, which is interactive and lets you pick a date. - Set a custom TODO sequence in your contacts file:
#+TODO: PING(p) INVITE(i) WRITE(w) PINGED(P!) FOLLOWUP(f) SKED(s) NOTES(N) SCHEDULED(S!) | TIMEOUT(t) OK(o)
- Take a pass at people you might want to follow up with right away:
PING
: Lightweight contact: Just a text “hi” or whatever.INVITE
: “want to grab lunch?”WRITE
: “Hey, it’s been a while …”PINGED
: Did a lightweight contact recently. Still considered “open” for purposes of contact.FOLLOWUP
: Set along with aSCHEDULED:
date for “let’s circle back” cases.SKED
: Promised to put time down, etc, so do the calendaringNOTES
: New contact. Don’t forget to put some notes in their record while fresh.SCHEDULED
: Something is on the calendar and pending, but hasn’t happened yet.TIMEOUT
: Pinged a while back, never heard back.OK
: Just a transitional state – recently did something with them, or don’t want them to turn up in agendas for whatever reason. But generally we prefer to z out the TODO state of a contact “at rest” so theCONTACTED
property can do its work.
- Having cleaned everything up, use the custom agenda views to keep track of contacts that have gone untouched for a period.
A pair of convenience functions to set the CONTACTED property quickly on a given contact. One just uses today, and one is interactive. Good for a quick touch during normal maintenance, or to run through your initial contact import.
(defun org-set-contacted-today ()
"Set the CONTACTED property of the current item to today's date."
(interactive)
(org-set-property "CONTACTED" (format-time-string "%Y-%m-%d")))
(defun org-set-contacted-date ()
"Set the CONTACTED property of the current item to a chosen date."
(interactive)
(let ((date (org-read-date nil t nil "Enter the date: ")))
(org-set-property "CONTACTED" (format-time-string "%Y-%m-%d" date))))
(map! :mode org-mode
:localleader
:desc "Set CONTACTED property to today"
"C t" #'org-set-contacted-today
"C d" #'org-set-contacted-date
"C z" #'my/org-remove-todo
)
I’m not sure if there’s a canonical way to de-TODO a heading without cycling through all the possible states, so I asked ChatGPT to make a function for me and gave it a Doom keybinding (c z
for “contact zee”). Its main use is just to de-TODO a contact from anywhere in the record.
(defun my/org-remove-todo ()
(interactive)
(org-set-property "TODO" ""))
(defun org-contacted-days-between (min-days max-days)
(let ((contacted-date (org-entry-get (point) "CONTACTED"))
(today (current-time)))
(when contacted-date
(let ((days-ago (time-to-number-of-days
(time-subtract today (org-time-string-to-time contacted-date)))))
(and (>= days-ago min-days) (< days-ago max-days))))))
(defun org-contacted-more-than-days-ago (days)
(let ((contacted-date (org-entry-get (point) "CONTACTED"))
(today (current-time)))
(when contacted-date
(> (time-to-number-of-days
(time-subtract today (org-time-string-to-time contacted-date)))
days))))
(defun org-contacted-never-p ()
(not (org-entry-get (point) "CONTACTED")))
This is a general-purpose state/time setter that isn’t meant to be used interactively on its own, but is instead meant to drive a collection of modal menus for common state changes. (See the keybindings just below this block.)
It takes TODO
state, number of days, and whether the date it sets is SCHEDULED:
or DEADLINE:
.
(defun my/org-set-heading-state-and-time (state days &optional time-type)
"Sets the TODO state and deadline or scheduled date of the current heading.
STATE is the new TODO state to set, and DAYS is the number
of days from the current date to set the new time. If TIME-TYPE
is 'd', sets a deadline; if 's', sets a scheduled date; otherwise,
prompts the user for the time type. Removes any existing schedules
or deadlines before setting the new time."
(interactive (list "WRITE" 7 nil))
(org-entry-put nil "TODO" state)
(when (org-entry-get nil "DEADLINE")
(org-entry-delete nil "DEADLINE"))
(when (org-entry-get nil "SCHEDULED")
(org-entry-delete nil "SCHEDULED"))
(let ((new-time (format-time-string "<%Y-%m-%d %a>"
(time-add (current-time) (days-to-time days)))))
(cond ((equal time-type 'd)
(org-deadline nil new-time))
((equal time-type 's)
(org-schedule nil new-time))
(t
(setq time-type (completing-read "Set time type (d/s): "))
(my/org-set-heading-state-and-time state days (if (string= time-type "d") 'd 's))))))
(global-set-key (kbd "C-c t") (lambda () (interactive) (my/org-set-heading-state-and-time "WRITE" 7 'd)))
Some keybindings for common activities:
- “Write within 7 days”
- “Remember to follow up in three days”
- “Remember to invite within three days”
- “
SCHEDULED:
for 30 days from now” – just to float them back up in a month.
(map! :mode org-mode
:localleader
:desc "Remember to write"
"C w" #'(lambda () (interactive)
(my/org-set-heading-state-and-time "WRITE" 7 'd))
:desc "Remember to followup"
"C f" #'(lambda () (interactive)
(my/org-set-heading-state-and-time "FOLLOWUP" 3 's))
:desc "Remember to invite"
"C i" #'(lambda () (interactive)
(my/org-set-heading-state-and-time "INVITE" 3 'd))
"C F" #'(lambda () (interactive)
(my/org-set-heading-state-and-time "" 30 's))
)
This adds two custom agenda commands meant to help you quickly zero in on three categories of contacts:
- Contacts in active
TODO
states (excluding “PINGED” and “SCHEDULED”) - Contacts tagged
:network:
who have a:CONTACTED:
property older than 90 days. - Contacts tagged
:close:
or:fam:
who have a:CONTACTED:
property older than 30 days.
The idea for these is that you can quickly surface these contacts and choose whether to move them into an active state of some kind (e.g. marking them for a PING
or INVITE
)
(add-to-list 'org-agenda-custom-commands
'("c" "Contacts TODOs"
((tags-todo "CATEGORY=\"contacts\"&-PINGED&-SCHEDULED"
((org-agenda-files '("~/org/contacts.org"))
(org-agenda-overriding-header "Contacts TODOs")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'todo '("PINGED" "SCHEDULED")))
(org-agenda-sorting-strategy '(tag-up))))))
nil
nil)
(add-to-list 'org-agenda-custom-commands
'("N" "Network last contacted > 90 days ago"
((tags "network"
((org-agenda-overriding-header "Network contacts, not contacted in the past 90 days")
(org-tags-match-list-sublevels t)
(org-agenda-skip-function
(lambda ()
(unless (org-contacted-more-than-days-ago 90)
(or (outline-next-heading)
(goto-char (point-max))))))))
)))
(add-to-list 'org-agenda-custom-commands
'("F" "Close people last contacted > 30 days ago"
((tags "close|fam"
((org-agenda-overriding-header "Close friends and family, not contacted in the past 30 days")
(org-tags-match-list-sublevels t)
(org-agenda-skip-function
(lambda ()
(unless (org-contacted-more-than-days-ago 30)
(or (outline-next-heading)
(goto-char (point-max))))))))
)))
This didn’t work the way I wanted so it’s untangled: The idea was to do some logic on the CONTACTED
property to set the priority of a contact. It felt needlessly complicated.
(defun my/org-update-priorities-based-on-contacted ()
(interactive)
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^\\*+ " nil t)
(let ((contacted-date (org-entry-get (point) "CONTACTED"))
(today (format-time-string "%Y-%m-%d")))
(when contacted-date
(let ((days-ago (- (time-to-days (current-time))
(time-to-days (org-time-string-to-time contacted-date))))) ; calculate days since CONTACTED date
(org-set-property "PRIORITY"
(cond
((< days-ago 45) "C")
((< days-ago 90) "B")
(t "A")))))))))
Accompanying hook to mass update contacts.org
on save.
(add-hook 'org-mode-hook
(lambda ()
(when (string-equal (buffer-file-name) "~/org/contacts.org")
(add-hook 'after-save-hook 'my/org-update-priorities-based-on-contacted nil 'make-it-local))))
When I forgot to initially set a CONTACTED
property but had already made some changes to my contacts.org
file I didn’t care to lose, I had ChatGPT write this function to buzzsaw through and set all CONTACTED
to 1971-01-01
to make sure the agenda views for recency worked. Currently disabled.
(defun set-contacted-to-1971-01-01 ()
"Set the CONTACTED property of every contact in contacts.org to 1971-01-01."
(interactive)
(with-current-buffer (find-file-noselect "contacts.org")
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^\*\* " nil t)
(org-set-property "CONTACTED" "1971-01-01")))))
Another buzzsaw to deprioritize everything in the buffer. As I was trying to dial in the abandoned priority updaterizer, I goofed a few times and wanted a way to flatten everything out again.
(require 'org)
(defun org-remove-all-priorities ()
"Remove priorities from all headings in the buffer."
(interactive)
(save-excursion
(goto-char (point-min))
(while (re-search-forward org-todo-line-regexp nil t)
(when (string-match org-priority-regexp (match-string 0))
(org-priority ?\ )))))
[fn:1] https://gist.github.com/pdxmph/dc36f05a536e7a8130dab3b8bc82430b