Skip to content

Instantly share code, notes, and snippets.

@pdxmph
Created April 18, 2023 22:28
Show Gist options
  • Save pdxmph/c24471962cdf18e4b781718944073a12 to your computer and use it in GitHub Desktop.
Save pdxmph/c24471962cdf18e4b781718944073a12 to your computer and use it in GitHub Desktop.
Functions, helpers, and agenda views to make a plaintext CRM in org-mode with org-contacts.

Org Plaintext Crm

org-contacts CRM

A bunch of functions and views to allow org-contacts to serve as the basis for a lightweight, text-based CRM.

Basic workflow:

  1. 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]
  2. Add a :CONTACTED: property to the cards. It takes a date (not an org-date, just an ISO-8601 YYYY-mm-dd date). The script adds one.
  3. 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.
  4. 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, and org-set-contacted-date, which is interactive and lets you pick a date.
  5. 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)
  6. 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 a SCHEDULED: date for “let’s circle back” cases.
    • SKED: Promised to put time down, etc, so do the calendaring
    • NOTES: 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 the CONTACTED property can do its work.
  7. Having cleaned everything up, use the custom agenda views to keep track of contacts that have gone untouched for a period.

Changed CONTACTED property to today or to a selected date

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
                )

Remove todo state

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" ""))

Helper: CONTACTED by days between intervals

(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))))))

Helper: Contacted more than x days ago

(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))))

Helper: Contacted never

(defun org-contacted-never-p ()
  (not (org-entry-get (point) "CONTACTED")))

Helper - set state and bump deadlines

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

Simplified CRM agenda

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

Update priorities based on recency of contact update (unused)

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

Helper Code

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 ?\ )))))

Footnotes

[fn:1] https://gist.github.com/pdxmph/dc36f05a536e7a8130dab3b8bc82430b

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