;;; org-contacts-config.el --- Org Contacts Configuration -*- lexical-binding: t; coding: utf-8; -*- ;; author: Craig Jennings ;; ;;; Commentary: ;; Configuration for org-contacts, providing contact management within org-mode. ;; Integrates with mu4e for email address completion and org-roam for linking ;; contacts to projects and notes. ;; ;; Email completion functionality has been moved to mu4e-org-contacts-integration.el ;;; Code: (require 'user-constants) ;; --------------------------- Org Agenda Integration -------------------------- (with-eval-after-load 'org-agenda ;; Remove the direct hook first (in case it's already added) (remove-hook 'org-agenda-finalize-hook 'org-contacts-anniversaries) ;; Add a wrapper function that ensures proper context (defun cj/org-contacts-anniversaries-safe () "Safely call org-contacts-anniversaries with required bindings." (require 'diary-lib) ;; These need to be dynamically bound for diary functions (defvar date) (defvar entry) (defvar original-date) (let ((date (calendar-current-date)) (entry "") (original-date (calendar-current-date))) (ignore-errors (org-contacts-anniversaries)))) ;; Use the safe wrapper instead (add-hook 'org-agenda-finalize-hook 'cj/org-contacts-anniversaries-safe)) ;; ----------------------- Org-Contacts Capture Template ----------------------- (defun cj/org-contacts-finalize-birthday-timestamp () "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set." (when (string= (plist-get org-capture-plist :key) "C") (save-excursion (goto-char (point-min)) (let ((birthday (org-entry-get (point) "BIRTHDAY"))) (when (and birthday (not (string-blank-p birthday))) ;; Parse birthday - returns (year month day) or nil (let ((parsed (cond ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday) (list (string-to-number (match-string 1 birthday)) (string-to-number (match-string 2 birthday)) (string-to-number (match-string 3 birthday)))) ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday) (list (nth 5 (decode-time)) (string-to-number (match-string 1 birthday)) (string-to-number (match-string 2 birthday)))) (t nil)))) (when parsed (let* ((year (nth 0 parsed)) (month (nth 1 parsed)) (day (nth 2 parsed)) (time (encode-time 0 0 0 day month year)) (dow (format-time-string "%a" time)) (timestamp (format "<%04d-%02d-%02d %s +1y>" year month day dow)) (heading-end (save-excursion (outline-next-heading) (point)))) ;; Find :END: and insert timestamp (when (re-search-forward "^[ \t]*:END:[ \t]*$" heading-end t) (let ((end-pos (point))) (goto-char end-pos) (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) (goto-char end-pos) (end-of-line) (insert "\n" timestamp)))))))))))) (with-eval-after-load 'org-capture (add-to-list 'org-capture-templates '("C" "Contact" entry (file+headline contacts-file "Contacts") "* %(cj/org-contacts-template-name) :PROPERTIES: :EMAIL: %(cj/org-contacts-template-email) :PHONE: %^{Phone(s) - separate multiple with commas} :ADDRESS: %^{Address} :BIRTHDAY: %^{Birthday (YYYY-MM-DD or MM-DD)} :NICKNAME: %^{Nickname} :COMPANY: %^{Company} :TITLE: %^{Title/Position} :WEBSITE: %^{URL} :NOTE: %^{Notes} :END: Added: %U" :prepare-finalize cj/org-contacts-finalize-birthday-timestamp))) ;; TASK: What purpose did this serve? ;; duplicate?!? ;; (with-eval-after-load 'org-capture ;; (add-to-list 'org-capture-templates ;; '("C" "Contact" entry (file+headline contacts-file "Contacts") ;; "* %(cj/org-contacts-template-name) ;; Added: %U"))) (defun cj/org-contacts-template-name () "Get name for contact template from context." (let ((name (when (boundp 'cj/contact-name) cj/contact-name))) (or name (when (eq major-mode 'mu4e-headers-mode) (mu4e-message-field (mu4e-message-at-point) :from-or-to)) (when (eq major-mode 'mu4e-view-mode) (mu4e-message-field mu4e~view-message :from-or-to)) (read-string "Name: ")))) (defun cj/org-contacts-template-email () "Get email for contact template from context." (let ((email (when (boundp 'cj/contact-email) cj/contact-email))) (or email (when (eq major-mode 'mu4e-headers-mode) (let ((from (mu4e-message-field (mu4e-message-at-point) :from))) (when from (cdr (car from))))) (when (eq major-mode 'mu4e-view-mode) (let ((from (mu4e-message-field mu4e~view-message :from))) (when from (cdr (car from))))) (read-string "Email: ")))) ;;; ------------------------- Quick Contact Functions --------------------------- (defun cj/org-contacts-find () "Find and open a contact." (interactive) (find-file contacts-file) (goto-char (point-min)) (let ((contact (completing-read "Contact: " (org-map-entries (lambda () (nth 4 (org-heading-components))) nil (list contacts-file))))) (goto-char (point-min)) (search-forward contact) (org-fold-show-entry) (org-reveal))) (defun cj/org-contacts-new () "Create a new contact." (interactive) (org-capture nil "C")) (defun cj/org-contacts-view-all () "View all contacts in a column view." (interactive) (find-file contacts-file) (org-columns)) ;;; ----------------------------- Birthday Agenda -------------------------------- (with-eval-after-load 'org-agenda ;; Add birthdays to agenda (setq org-agenda-include-diary t) ;; Custom agenda command for upcoming birthdays (add-to-list 'org-agenda-custom-commands '("b" "Birthdays and Anniversaries" ((tags-todo "BIRTHDAY|ANNIVERSARY" ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries") (org-agenda-sorting-strategy '(time-up)))))))) ;;; ---------------------------- Core Contact Data Functions --------------------------- (defun cj/org-contacts--props-matching (entry pattern) "Return all property values from ENTRY whose keys match PATTERN (a regexp)." (let ((props (nth 2 entry))) (delq nil (mapcar (lambda (prop) (when (string-match-p pattern (car prop)) (cdr prop))) props)))) (defun cj/--parse-email-string (name email-string) "Parse EMAIL-STRING and return formatted entries for NAME. EMAIL-STRING may contain multiple emails separated by commas, semicolons, or spaces. Returns a list of strings formatted as 'Name '. Returns nil if EMAIL-STRING is nil or contains only whitespace." (when (and email-string (string-match-p "[^[:space:]]" email-string)) (let ((emails (split-string email-string "[,;[:space:]]+" t))) (mapcar (lambda (email) (format "%s <%s>" name (string-trim email))) emails)))) (defun cj/get-all-contact-emails () "Retrieve all contact emails from org-contacts database. Returns a list of formatted strings like \"Name \". This is the core function used by the mu4e integration module." (let ((contacts (org-contacts-db))) (delq nil (mapcan (lambda (e) (let* ((name (car e)) ;; This returns a LIST of email strings (email-strings (cj/org-contacts--props-matching e "EMAIL"))) ;; Process each email string using the extracted parser (mapcan (lambda (email-str) (cj/--parse-email-string name email-str)) email-strings))) contacts)))) ;; Simple insertion function for use outside of mu4e (defun cj/insert-contact-email () "Select and insert a contact's email address at point. For use outside of mu4e compose buffers. In mu4e, the integration module provides more sophisticated completion." (interactive) (let* ((items (cj/get-all-contact-emails)) (selected (completing-read "Contact: " items nil t))) (insert selected))) ;;; -------------------------------- Org Contacts -------------------------------- (use-package org-contacts :after (org mu4e) :custom (org-contacts-files (list contacts-file)) :config (require 'mu4e) ;; Basic settings (setq org-contacts-icon-use-gravatar nil) ; Don't fetch gravatars ;; Birthday and anniversary handling (setq org-contacts-birthday-format "It's %l's birthday today! 🎂") ;; Email address formatting (setq org-contacts-email-link-description-format "%s <%e>") (setq mu4e-org-contacts-file contacts-file) (add-to-list 'mu4e-headers-actions '("org-contact-add" . mu4e-action-add-org-contact) t) (add-to-list 'mu4e-view-actions '("org-contact-add" . mu4e-action-add-org-contact) t) ;; Disable mu4e's built-in completion in favor of our custom solution (setq mu4e-compose-complete-addresses nil)) ;;; ---------------------------- Org-Contacts Keymap ---------------------------- ;; Keymap for `org-contacts' commands (defvar cj/org-contacts-map (let ((map (make-sparse-keymap))) (keymap-set map "f" #'cj/org-contacts-find) ;; find contact (keymap-set map "n" #'cj/org-contacts-new) ;; new contact (keymap-set map "e" #'cj/insert-contact-email) ;; inserts email from org-contact (keymap-set map "v" #'cj/org-contacts-view-all) ;; view all contacts map) "Keymap for `org-contacts' commands.") ;; Bind the org-contacts map to the C-c C prefix (keymap-global-set "C-c C" cj/org-contacts-map) ;; which-key labels (with-eval-after-load 'which-key (which-key-add-key-based-replacements "C-c C" "contacts menu" "C-c C f" "find contact" "C-c C n" "new contact" "C-c C e" "insert email" "C-c C v" "view all contacts")) (provide 'org-contacts-config) ;;; org-contacts-config.el ends here