;;; org-contacts.el --- Contacts management ;; Copyright (C) 2010, 2011 Julien Danjou ;; Author: Julien Danjou ;; Keywords: outlines, hypermedia, calendar ;; ;; This file is NOT part of GNU Emacs. ;; ;; GNU Emacs is free software: you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; GNU Emacs is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with GNU Emacs. If not, see . ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;;; Commentary: ;; This file contains the code for managing your contacts into Org-mode. ;; To enter new contacts, you can use `org-capture' and a template just like ;; this: ;; ("c" "Contacts" entry (file "~/Org/contacts.org") ;; "* %(org-contacts-template-name) ;; :PROPERTIES: ;; :EMAIL: %(org-contacts-template-email) ;; :END:"))) ;; ;;; Code: (eval-when-compile (require 'cl)) (eval-and-compile (require 'org)) (defgroup org-contacts nil "Options concerning contacts management." :group 'org) (defcustom org-contacts-files nil "List of Org files to use as contacts source. If set to nil, all your Org files will be used." :type '(repeat file) :group 'org-contacts) (defcustom org-contacts-email-property "EMAIL" "Name of the property for contact email address." :type 'string :group 'org-contacts) (defcustom org-contacts-address-property "ADDRESS" "Name of the property for contact address." :type 'string :group 'org-contacts) (defcustom org-contacts-birthday-property "BIRTHDAY" "Name of the property for contact birthday date." :type 'string :group 'org-contacts) (defcustom org-contacts-birthday-format "Birthday: %l (%Y)" "Format of the anniversary agenda entry. The following replacements are available: %h - Heading name %l - Link to the heading %y - Number of year %Y - Number of year (ordinal)" :type 'string :group 'org-contacts) (defcustom org-contacts-last-read-mail-property "LAST_READ_MAIL" "Name of the property for contact last read email link storage." :type 'string :group 'org-contacts) (defcustom org-contacts-icon-property "ICON" "Name of the property for contact icon." :type 'string :group 'org-contacts) (defcustom org-contacts-nickname-property "NICKNAME" "Name of the property for IRC nickname match." :type 'string :group 'org-contacts) (defcustom org-contacts-icon-size 32 "Size of the contacts icons." :type 'string :group 'org-contacts) (defcustom org-contacts-icon-use-gravatar (fboundp 'gravatar-retrieve) "Whether use Gravatar to fetch contact icons." :type 'boolean :group 'org-contacts) (defcustom org-contacts-completion-ignore-case t "Ignore case when completing contacts." :type 'boolean :group 'org-contacts) (defcustom org-contacts-group-prefix "+" "Group prefix." :type 'string :group 'org-contacts) (defcustom org-contacts-matcher (concat org-contacts-email-property "<>\"\"") "Matching rule for finding heading that are contacts. This can be a tag name, or a property check." :type 'string :group 'org-contacts) (defcustom org-contacts-email-link-description-format "%s (%d)" "Format used to store links to email. This overrides `org-email-link-description-format' if set." :group 'org-contacts :type 'string) (defcustom org-contacts-vcard-file "contacts.vcf" "Default file for vcard export." :group 'org-contacts :type 'file) (defvar org-contacts-keymap (let ((map (make-sparse-keymap))) (define-key map "M" 'org-contacts-view-send-email) (define-key map "i" 'org-contacts-view-switch-to-irc-buffer) map) "The keymap used in `org-contacts' result list.") (defun org-contacts-files () "Return list of Org files to use for contact management." (or org-contacts-files (org-agenda-files t 'ifmode))) (defun org-contacts-filter (&optional name-match tags-match) "Search for a contact maching NAME-MATCH and TAGS-MATCH. If both match values are nil, return all contacts." (let ((tags-matcher (if tags-match (cdr (org-make-tags-matcher tags-match)) t)) (name-matcher (if name-match '(org-string-match-p name-match (org-get-heading t)) t)) (contacts-matcher (cdr (org-make-tags-matcher org-contacts-matcher))) markers result) (dolist (file (org-contacts-files)) (org-check-agenda-file file) (with-current-buffer (org-get-agenda-file-buffer file) (unless (org-mode-p) (error "File %s is no in `org-mode'" file)) (org-scan-tags '(add-to-list 'markers (set-marker (make-marker) (point))) `(and ,contacts-matcher ,tags-matcher ,name-matcher)))) (dolist (marker markers result) (org-with-point-at marker (add-to-list 'result (list (org-get-heading t) marker (org-entry-properties marker 'all))))))) (when (not (fboundp 'completion-table-case-fold)) ;; That function is new in Emacs 24... (defun completion-table-case-fold (table string pred action) (let ((completion-ignore-case t)) (complete-with-action action table string pred)))) (defun org-contacts-complete-name (&optional start) "Complete text at START with a user name and email." (let* ((end (point)) (start (or start (save-excursion (re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*") (goto-char (match-end 0)) (point)))) (orig (buffer-substring start end)) (completion-ignore-case org-contacts-completion-ignore-case) (group-completion-p (org-string-match-p (concat "^" org-contacts-group-prefix) orig)) (completion-list (if group-completion-p (mapcar (lambda (group) (propertize (concat org-contacts-group-prefix group) 'org-contacts-group group)) (org-uniquify (loop for contact in (org-contacts-filter) with group-list nconc (org-split-string (or (cdr (assoc-string "ALLTAGS" (caddr contact))) "") ":")))) (loop for contact in (org-contacts-filter) ;; The contact name is always the car of the assoc-list ;; returned by `org-contacts-filter'. for contact-name = (car contact) ;; Build the list of the user email addresses. for email-list = (split-string (or (cdr (assoc-string org-contacts-email-property (caddr contact))) "")) ;; If the user has email addresses… if email-list ;; … append a list of USER . nconc (loop for email in email-list collect (org-contacts-format-email contact-name email))))) (completion-list (all-completions orig completion-list))) ;; If we are completing a group, and that's the only group, just return ;; the real result. (when (and group-completion-p (= (length completion-list) 1)) (setq completion-list (list (concat (car completion-list) ";: " (mapconcat 'identity (loop for contact in (org-contacts-filter nil (get-text-property 0 'org-contacts-group (car completion-list))) ;; The contact name is always the car of the assoc-list ;; returned by `org-contacts-filter'. for contact-name = (car contact) ;; Grab the first email of the contact for email = (car (split-string (or (cdr (assoc-string org-contacts-email-property (caddr contact))) ""))) ;; If the user has an email address, append USER . if email collect (org-contacts-format-email contact-name email)) ", "))))) (list start end (if org-contacts-completion-ignore-case (apply-partially #'completion-table-case-fold completion-list) completion-list)))) (defun org-contacts-message-complete-function () "Function used in `completion-at-point-functions' in `message-mode'." (let ((mail-abbrev-mode-regexp "^\\(Resent-To\\|To\\|B?Cc\\|Reply-To\\|From\\|Mail-Followup-To\\|Mail-Copies-To\\|Disposition-Notification-To\\|Return-Receipt-To\\):")) (when (mail-abbrev-in-expansion-header-p) (org-contacts-complete-name)))) (defun org-contacts-gnus-get-name-email () "Get name and email address from Gnus message." (gnus-with-article-headers (mail-extract-address-components (or (mail-fetch-field "From") "")))) (defun org-contacts-gnus-article-from-get-marker () "Return a marker for a contact based on From." (let* ((address (org-contacts-gnus-get-name-email)) (name (car address)) (email (cadr address))) (cadar (or (org-contacts-filter nil (concat org-contacts-email-property "={\\b" (regexp-quote email) "\\b}")) (when name (org-contacts-filter (concat "^" name "$"))))))) (defun org-contacts-gnus-article-from-goto () "Go to contact in the From address of current Gnus message." (interactive) (let ((marker (org-contacts-gnus-article-from-get-marker))) (when marker (switch-to-buffer-other-window (marker-buffer marker)) (goto-char marker) (when (org-mode-p) (org-show-context 'agenda) (save-excursion (and (outline-next-heading) ;; show the next heading (org-flag-heading nil))))))) (defun org-contacts-anniversaries (&optional field format) "Compute FIELD anniversary for each contact, returning FORMAT. Default FIELD value is \"BIRTHDAY\". Format is a string matching the following format specification: %h - Heading name %l - Link to the heading %y - Number of year %Y - Number of year (ordinal)" (let ((calendar-date-style 'american) (entry "")) (unless format (setq format org-contacts-birthday-format)) (loop for contact in (org-contacts-filter) for anniv = (let ((anniv (cdr (assoc-string (or field org-contacts-birthday-property) (caddr contact))))) (when anniv (calendar-gregorian-from-absolute (org-time-string-to-absolute anniv)))) ;; Use `diary-anniversary' to compute anniversary. if (and anniv (apply 'diary-anniversary anniv)) collect (format-spec format `((?l . ,(org-with-point-at (cadr contact) (org-store-link nil))) (?h . ,(car contact)) (?y . ,(- (calendar-extract-year date) (calendar-extract-year anniv))) (?Y . ,(let ((years (- (calendar-extract-year date) (calendar-extract-year anniv)))) (format "%d%s" years (diary-ordinal-suffix years))))))))) (defun org-completing-read-date (prompt collection &optional predicate require-match initial-input hist def inherit-input-method) "Like `completing-read' but reads a date. Only PROMPT and DEF are really used." (org-read-date nil nil nil prompt nil def)) (add-to-list 'org-property-set-functions-alist `(,org-contacts-birthday-property . org-completing-read-date)) (defun org-contacts-template-name (&optional return-value) "Try to return the contact name for a template. If not found return RETURN-VALUE or something that would ask the user." (or (car (org-contacts-gnus-get-name-email)) return-value "%^{Name}")) (defun org-contacts-template-email (&optional return-value) "Try to return the contact email for a template. If not found return RETURN-VALUE or something that would ask the user." (or (cadr (org-contacts-gnus-get-name-email)) return-value (concat "%^{" org-contacts-email-property "}p"))) (defun org-contacts-gnus-store-last-mail () "Store a link between mails and contacts. This function should be called from `gnus-article-prepare-hook'." (let ((marker (org-contacts-gnus-article-from-get-marker))) (when marker (with-current-buffer (marker-buffer marker) (save-excursion (goto-char marker) (let* ((org-email-link-description-format (or org-contacts-email-link-description-format org-email-link-description-format)) (link (gnus-with-article-buffer (org-store-link nil)))) (org-set-property org-contacts-last-read-mail-property link))))))) (defun org-contacts-icon-as-string () (let ((image (org-contacts-get-icon))) (concat (propertize "-" 'display (append (if image image `'(space :width (,org-contacts-icon-size))) '(:ascent center))) " "))) ;;;###autoload (defun org-contacts (name) "Create agenda view for contacts matching NAME." (interactive (list (read-string "Name: "))) (let ((org-agenda-files (org-contacts-files)) (org-agenda-skip-function (lambda () (org-agenda-skip-if nil `(notregexp ,name)))) (org-agenda-format (propertize "%(org-contacts-icon-as-string)% p% s%(org-contacts-irc-number-of-unread-messages)%+T" 'keymap org-contacts-keymap)) (org-agenda-overriding-header (or org-agenda-overriding-header (concat "List of contacts matching `" name "':")))) (setq org-agenda-skip-regexp name) (org-tags-view nil org-contacts-matcher) (with-current-buffer org-agenda-buffer-name (setq org-agenda-redo-command (list 'org-contacts name))))) (defun org-contacts-completing-read (prompt &optional predicate initial-input hist def inherit-input-method) "Call `completing-read' with contacts name as collection." (org-completing-read prompt (org-contacts-filter) predicate t initial-input hist def inherit-input-method)) (defun org-contacts-format-email (name email) "Format a mail address." (unless email (error "`email' cannot be nul")) (if name (concat name " <" email ">") email)) (defun org-contacts-check-mail-address (mail) "Add MAIL address to contact at point if it does not have it." (let ((mails (org-entry-get (point) org-contacts-email-property))) (unless (member mail (split-string mails)) (when (yes-or-no-p (format "Do you want to this address to %s?" (org-get-heading t))) (org-set-property org-contacts-email-property (concat mails " " mail)))))) (defun org-contacts-gnus-check-mail-address () "Check that contact has the current address recorded. This function should be called from `gnus-article-prepare-hook'." (let ((marker (org-contacts-gnus-article-from-get-marker))) (when marker (org-with-point-at marker (org-contacts-check-mail-address (cadr (org-contacts-gnus-get-name-email))))))) (defun org-contacts-gnus-insinuate () "Add some hooks for Gnus user. This adds `org-contacts-gnus-check-mail-address' and `org-contacts-gnus-store-last-mail' to `gnus-article-prepare-hook'. It also adds a binding on `;' in `gnus-summary-mode-map' to `org-contacts-gnus-article-from-goto'" (require 'gnus) (require 'gnus-art) (define-key gnus-summary-mode-map ";" 'org-contacts-gnus-article-from-goto) (add-hook 'gnus-article-prepare-hook 'org-contacts-gnus-check-mail-address) (add-hook 'gnus-article-prepare-hook 'org-contacts-gnus-store-last-mail)) (when (boundp 'completion-at-point-functions) (add-hook 'message-mode-hook (lambda () (add-to-list 'completion-at-point-functions 'org-contacts-message-complete-function)))) (defun org-contacts-wl-get-from-header-content () "Retrieve the content of the `From' header of an email. Works from wl-summary-mode and mime-view-mode - that is while viewing email. Depends on Wanderlust been loaded." (with-current-buffer (org-capture-get :original-buffer) (cond ((eq major-mode 'wl-summary-mode) (when wl-summary-buffer-elmo-folder (elmo-message-field wl-summary-buffer-elmo-folder (wl-summary-message-number) 'from))) ((eq major-mode 'mime-view-mode) (std11-narrow-to-header) (prog1 (std11-fetch-field "From") (widen)))))) (defun org-contacts-wl-get-name-email () "Get name and email address from wanderlust email. See `org-contacts-wl-get-from-header-content' for limitations." (let ((from (org-contacts-wl-get-from-header-content))) (when from (list (wl-address-header-extract-realname from) (wl-address-header-extract-address from))))) (defun org-contacts-template-wl-name (&optional return-value) "Try to return the contact name for a template from wl. If not found return RETURN-VALUE or something that would ask the user." (or (car (org-contacts-wl-get-name-email)) return-value "%^{Name}")) (defun org-contacts-template-wl-email (&optional return-value) "Try to return the contact email for a template from wl. If not found return RETURN-VALUE or something that would ask the user." (or (cadr (org-contacts-wl-get-name-email)) return-value (concat "%^{" org-contacts-email-property "}p"))) (defun org-contacts-view-send-email (&optional ask) "Send email to the contact at point. If ASK is set, ask for the email address even if there's only one address." (interactive "P") (let ((marker (org-get-at-bol 'org-hd-marker))) (org-with-point-at marker (let ((emails (org-entry-get (point) org-contacts-email-property))) (if emails (let ((email-list (split-string emails))) (if (and (= (length email-list) 1) (not ask)) (compose-mail (org-contacts-format-email (org-get-heading t) emails)) (let ((email (completing-read "Send mail to which address: " email-list))) (org-contacts-check-mail-address email) (compose-mail (org-contacts-format-email (org-get-heading t) email))))) (error (format "This contact has no mail address set (no %s property)." org-contacts-email-property))))))) (defun org-contacts-get-icon (&optional pom) "Get icon for contact at POM." (setq pom (or pom (point))) (catch 'icon ;; Use `org-contacts-icon-property' (let ((image-data (org-entry-get pom org-contacts-icon-property))) (when image-data (throw 'icon (if (fboundp 'gnus-rescale-image) (gnus-rescale-image (create-image image-data) (cons org-contacts-icon-size org-contacts-icon-size)) (create-image image-data))))) ;; Next, try Gravatar (when org-contacts-icon-use-gravatar (let* ((gravatar-size org-contacts-icon-size) (email-list (org-entry-get pom org-contacts-email-property)) (gravatar (when email-list (loop for email in (split-string email-list) for gravatar = (gravatar-retrieve-synchronously email) if (and gravatar (not (eq gravatar 'error))) return gravatar)))) (when gravatar (throw 'icon gravatar)))))) (defun org-contacts-irc-buffer (&optional pom) "Get the IRC buffer associated with the entry at POM." (setq pom (or pom (point))) (let ((nick (org-entry-get pom org-contacts-nickname-property))) (when nick (let ((buffer (get-buffer nick))) (when buffer (with-current-buffer buffer (when (eq major-mode 'erc-mode) buffer))))))) (defun org-contacts-irc-number-of-unread-messages (&optional pom) "Return the number of unread messages for contact at POM." (when (boundp 'erc-modified-channels-alist) (let ((number (cadr (assoc (org-contacts-irc-buffer pom) erc-modified-channels-alist)))) (if number (format (concat "%3d unread message" (if (> number 1) "s" " ") " ") number) (make-string 21 ? ))))) (defun org-contacts-view-switch-to-irc-buffer () "Switch to the IRC buffer of the current contact if it has one." (interactive) (let ((marker (org-get-at-bol 'org-hd-marker))) (org-with-point-at marker (switch-to-buffer-other-window (org-contacts-irc-buffer))))) (defun org-contacts-completing-read-nickname (prompt collection &optional predicate require-match initial-input hist def inherit-input-method) "Like `completing-read' but reads a nickname." (org-completing-read prompt (append collection (erc-nicknames-list)) predicate require-match initial-input hist def inherit-input-method)) (defun erc-nicknames-list () "Return all nicknames of all ERC buffers." (loop for buffer in (erc-buffer-list) nconc (with-current-buffer buffer (loop for user-entry in (mapcar 'car (erc-get-channel-user-list)) collect (elt user-entry 1))))) (add-to-list 'org-property-set-functions-alist `(,org-contacts-nickname-property . org-contacts-completing-read-nickname)) (defun org-contacts-vcard-escape (str) "Escape ; , and \n in STR for use in the VCard format. Thanks to http://www.emacswiki.org/cgi-bin/wiki/bbdb-vcard-export.el for the regexp." (when str (replace-regexp-in-string "\n" "\\\\n" (replace-regexp-in-string "\\(;\\|,\\|\\\\\\)" "\\\\\\1" str)))) (defun org-contacts-vcard-encode-name (name) "Try to encode NAME as VCard's N property. The N property expects FamilyName;GivenName;AdditionalNames;Prefix;Postfix. Org-contacts does not specify how to encode the name. So we try to do our best." (concat (replace-regexp-in-string "\\(\\w+\\) \\(.*\\)" "\\2;\\1" name) ";;;")) (defun org-contacts-vcard-format (contact) "Formats CONTACT in VCard 3.0 format." (let* ((properties (caddr contact)) (name (org-contacts-vcard-escape (car contact))) (n (org-contacts-vcard-encode-name name)) (email (org-contacts-vcard-escape (cdr (assoc-string org-contacts-email-property properties)))) (bday (org-contacts-vcard-escape (cdr (assoc-string org-contacts-birthday-property properties)))) (addr (cdr (assoc-string org-contacts-address-property properties))) (nick (org-contacts-vcard-escape (cdr (assoc-string org-contacts-nickname-property properties)))) (head (format "BEGIN:VCARD\nVERSION:3.0\nN:%s\nFN:%s\n" n name))) (concat head (when email (format "EMAIL:%s\n" email)) (when addr (format "ADR:;;%s\n" (replace-regexp-in-string "\\, ?" ";" addr))) (when bday (let ((cal-bday (calendar-gregorian-from-absolute (org-time-string-to-absolute bday)))) (format "BDAY:%04d-%02d-%02d\n" (calendar-extract-year cal-bday) (calendar-extract-month cal-bday) (calendar-extract-day cal-bday)))) (when nick (format "NICKNAME:%s\n" nick)) "END:VCARD\n\n"))) (defun org-contacts-export-as-vcard (&optional name file to-buffer) "Export all contacts matching NAME as VCard 3.0. It TO-BUFFER is nil, the content is written to FILE or `org-contacts-vcard-file'. If TO-BUFFER is non-nil, the buffer is created and the VCard is written into that buffer." (interactive) ; TODO ask for name? (let* ((filename (or file org-contacts-vcard-file)) (buffer (if to-buffer (get-buffer-create to-buffer) (find-file-noselect filename)))) (message "Exporting...") (set-buffer buffer) (let ((inhibit-read-only t)) (erase-buffer)) (fundamental-mode) (org-install-letbind) (when (fboundp 'set-buffer-file-coding-system) (set-buffer-file-coding-system coding-system-for-write)) (loop for contact in (org-contacts-filter name) do (insert (org-contacts-vcard-format contact))) (if to-buffer (current-buffer) (progn (save-buffer) (kill-buffer))))) (defun org-contacts-show-map (&optional name) "Show contacts on a map. Requires google-maps-el." (interactive) (unless (fboundp 'google-maps-static-show) (error "org-contacts-show-map requires google-maps-el.")) (google-maps-static-show :markers (loop for contact in (org-contacts-filter name) for addr = (cdr (assoc-string org-contacts-address-property (caddr contact))) if addr collect (cons (list addr) (list :label (string-to-char (car contact))))))) (provide 'org-contacts)