Making a plaintext personal CRM with org-contacts

ยท 1806 words ยท 9 minute read

This morning Al and I took our coffee walk, but she had to hop on a call, so for the two-mile walk back home I had some time to think about the habit on my list that popped up today: Social Maintenance and I also happen to have, for assorted reasons, a massive amount of poorly directed nervous energy. I am scattered and my thoughts are darting all over the place, and there’s enough jittery energy built up that the thought of cycling through a bunch of “what if I try this” stuff is sort of comforting.

I started trying to cultivate a social maintenance habit with the thought in mind that I had no idea what I was really thinking, just that during this current period it is important to me to keep up social contact in ways large and small. Pretty soon thereafter I realized I had an organizational problem on my hands: My address books were kind of a mess. Not very well organized, old data, a ton of contacts with old work addresses, etc. I spent a day straightening that out and got to a place of mostly clean.

The next problem that presented itself was that “personal CRM” is just an awful software category. Whenever I see a new contact management app, I think “oh, this is surely the one that will let you do something with your existing information, or add useful information,” but it never seems to be. The more competent looking entries in the market cost a lot. Searching yields a lot of “make one in Trello,” “make one in Notion,” etc.

I just stopped thinking about it and decided “do it off the top of your head until something comes up for you.”

So this morning, Al was on her call, and I had a Social Maintenance habit popping up on my agenda as a thing I was supposed to do today, and I’d been reading about org-contacts, org-vcard, and the ways they can integrate with an org-mode agenda to show birthdays or other anniversaries. By a few blocks later I’d thought of how I might wedge CRM-like data into that system with org :PROPERTIES: drawers:

  • desired frequency
  • date last contacted
  • notes on the last contact

This is all stuff you could do in a spreadsheet, and I think a lot of people do it that way. I have an aversion to spreadsheet applications, though.

By the time we were home, I had the beginnings of a plan: Export my macOS address book to a big vcard file, use org-vcard to import it into a contacts file, then start figuring out the mechanics of adding the fields I needed to drive org agenda views.

Getting my contacts into org-contacts ๐Ÿ”—

There’s an org-vcard package that theoretically handles the process of moving a vcard file into an org file. The maintainer has announced that they’re not going to work on it any longer, and it seems to have problems with macOS Contacts output.

I put together a script (well, ChatGPT and I put together a script) that parses a VCF file and dumps the contacts out into the right format. I just cat’d its output into the right file. It is probably best described as a menace to your data. I had so recently scrubbed my contacts that I trusted it enough.

An org-contacts record looks like this:

\* Joe Grudd :social:
:PROPERTIES:
:EMAIL: [email protected]
:WEBSITE: http://joe.grudd.com
:CONTACTED: 2023-04-12
:END:

There are a few other fields, like birthday and physical address, too.

On its own, it doesn’t do a ton. You can add notes to a :NOTES: property if you like, and you can search the entire file with an org-contacts command that lists results instead of just doing a normal text search operation.

Tracking contact information ๐Ÿ”—

There are a few ways I thought of to come at what I wanted to do, which amounted to:

  • Keeping track of whom I’ve had contact with

  • Keeping track of when I last had contact with someone

  • Keeping track of useful details about people

  • Surfacing people I haven’t seen in a while

    The NOTES property in a vcard record is fine, but org-mode provides a way to add a log to each record in its own drawer, which changes the record to look like this:

\* Joe Grudd :social:
:PROPERTIES:
:EMAIL: [email protected]
:WEBSITE: http://joe.grudd.com
:CONTACTED: 2023-04-12
:END:
:LOGBOOK:
  - Note taken on [2023-04-12 Wed 11:16] \\
    Caught up over IM for the first time in a while. He's moving to California next month.
  :END:

org-mode pretties all this stuff up, so the LOGBOOK and PROPERTIES drawers aren’t always visible.

I also added the CONTACTED field to PROPERTIES. It’s just an ISO-8601 date meant to reflect the last time I had some kind of contact, even if it’s just a ping.

So at this point, I could just use this as is and it’d be no worse than a spreadsheet.

Automating updates ๐Ÿ”—

I wanted a way to quickly note a contact “touch” so I made a few functions for that:

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

Those two allow me to set the CONTACTED property either to today’s date (spc m c t), or by interactively selecting a date (spc m c d). There’s a third mapping that lets me z out the TODO status of a contact (spc m c z), which I will get to.

Agenda customization ๐Ÿ”—

Next up, I wanted some kind of agenda automation – custom views that’d let me see contacts overdue for some kind of ping. I made a few driven by a combination of tags and age of the CONTACTED field.

Here’s one of them, driven by a function that finds aged contacts:

(add-to-list 'org-agenda-custom-commands
             '("N" "Professional 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))))))))
                )))

So in Doom, I can tap spc oAN and get a list of contacts tagged with network whom I haven’t had any contact with for more than 90 days.

Setting priority by touch date ๐Ÿ”—

I wanted a way to see which contacts were aging and decided to use plain old priorities for that, so a function looks at the difference between today and CONTACTED and prioritizes more aged contacts higher:


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

It’s connected to a save hook, so every contact’s priority gets recalculated at save:


(add-hook 'after-save-hook
          (lambda ()
            (when (string-equal (buffer-file-name) "~/org/contacts.org")
              (my/org-update-priorities-based-on-contacted ))))

That prioritization shows up in the agenda views I set up, with [A] priority contacts getting their own section.

Custom TODO states ๐Ÿ”—

Finally, I wanted a way to keep track of where a given contact is, or cue myself on next steps, so I set up custom TODO states just for my contacts.org file:

#+TODO: PING(p) PINGED(P!) FOLLOWUP(f) SKED(s) | TIMEOUT(t) OK(o)

By putting a ! inside the shortcut parens, org-mode will automatically log changes in and out of those states. For now I just have PINGED wired up that way, and TIMEOUT and OK, as DONE equivalents will similarly trigger a log entry.

FOLLOWUP and SKED are there as reminders that I need to do something next. TIMEOUT is a way to tell myself I gave it a shot and nothing came of it. OK is just an interim state on the way to no state until the agenda surfaces someone again.

The logging for these state changes looks like this in a given contact entry:

\* PINGED Joe Grudd :social:
:PROPERTIES:
:EMAIL: [email protected]
:WEBSITE: http://joe.grudd.com
:CONTACTED: 2023-04-12
:END:
:LOGBOOK:
- State "PINGED"     from "PING"   [2023-04-11 Tue 20:21]
- Note taken on [2023-04-12 Wed 11:16] \\
  Caught up over text for the first time in a while. He's moving to California next month.
:END:

This is also where that last “z” keybinding comes in:

(defun my/org-remove-todo ()
  (interactive)
  (org-set-property "TODO" ""))

With spc m c z I can zero out the TODO state of a given contact without triggering a log entry, keeping a little bit of noise down.

What else? ๐Ÿ”—

That’s the system. I guess the summary is:

“I’ve used org-contacts to keep my contacts in a plaintext file. By using existing data features and agenda customizations, I get prompts that will help me cultivate my Social Maintenance habit when it’s due. With a few custom functions and a save hook, I can use light automation to make the text more dynamic without a lot of day-to-day effort.”

And I’m a little more clear on what “social maintenance” can mean, now. I think I’d created an ill-defined monster the longer I let it sit there with no shape. As I put this together I got to think about what would be meaningful, and I realized it just makes my day when I get a text from someone asking how it’s going, so that’s a fine standard to apply.

And yes, it was an interesting ChatGPT exercise. It would have taken me days to suss all this out on my own. I just don’t have the elisp. It took much less time just dialoging with the bot, and it let me work much more iteratively if an idea didn’t test quite right. I wonder how this would have gone if I’d thought of trying it when I was using Obsidian a lot, or if I’d been in more of a Rails or Sinatra mood.

I think the whole thing will seem like overkill to some, but I am not good at keeping up with people. I am not going to go all autobiographical to explain it, I’m just gonna say that there is what I want to do and there is what I do, and they aren’t aligned, and I know enough about myself to know that in the absence of a supporting system my good intentions will not mean anything.

And I’ve had a few recent interactions with people I haven’t spoken to in a long time. It feels really good to reconnect, even if it’s just a few lines of “what’s up with you?” So I’ve built a system to help me get more of that.