Why bother?
My wife and I keep our address books synced on our phones and computers.
We shouldn't have to retype or reformat all that information onto mailing labels when the holidays roll around.
Apple's Address Book program tries to be smart about printing labels when it detects spouses and children.
The problem is that it's smart in a different way with each revision to OS X.
Each year, my wife has had to figure out the new rules for how to encode spouses and partners, and then reformat the address book.
It's a pain.
So, this year, I permanently solved the problem with about 150 lines of C and 150 lines of Racket.
We dumped the Address Book data and formatted labels for printing according to our own standard. It took less time than reformatting by hand.
Dumping the OS X Address Book
The OS X Address Book has a C API and an Objective-C API. Both have a roughly one-to-one mapping with each other.
I originally picked the C API for dumping the Address Book data, but only because I'd done more C hacking than Objective-C hacking.
[Update: I had the chance to rewrite it in Objective-C, and it's substantially cleaner.]
abdump.c (or abdump.m) accepts a group name as its first argument, and dumps each person in the group as an S-Expression:
#include <stdio.h> #include <stdlib.h> #include <strings.h> #include <AddressBook/ABAddressBookC.h> #include <AddressBook/ABGlobalsC.h> #include <CoreFoundation/CoreFoundation.h> // The global address book: ABAddressBookRef AB ; // Convert a Core Foundation string // to a null-terminated C string: const char* cstring (CFStringRef s) { return (const char*) CFStringGetCStringPtr(s, kCFStringEncodingMacRoman) ; } int main (int argc, const char* argv[]) { // By default, choose the group Family: const char* groupName = "Family" ; // Usage: $0 ["<group name>"] // Ex: $0 "Holiday Card List" if (argc >= 2) groupName = argv[1] ; CFStringRef cfGroupName = CFStringCreateWithBytes(NULL, (UInt8*)groupName, strlen(groupName), kCFStringEncodingMacRoman, false); // Find that group: ABSearchElementRef matchGroupName = ABGroupCreateSearchElement(kABGroupNameProperty, NULL, NULL, cfGroupName, kABEqualCaseInsensitive); // Grab the global, shared address book: AB = ABGetSharedAddressBook(); // All groups with that name: CFArrayRef groupsFound = ABCopyArrayOfMatchingRecords(AB, matchGroupName) ; if (CFArrayGetCount(groupsFound) == 0) { printf("error: couldn't find address book group '%s'\n", groupName); exit(-1) ; } ABGroupRef matchedGroup = (ABGroupRef)CFArrayGetValueAtIndex(groupsFound, 0); // All people of the matched group: CFArrayRef members = ABGroupCopyArrayOfAllMembers(matchedGroup) ; // Total number of people: int len = CFArrayGetCount(members) ; // Give each entry a unique id: int uid = 0 ; // Iterate over all people: for (int i = 0; i < len; ++i) { ABPersonRef person = (ABPersonRef)CFArrayGetValueAtIndex(members, i) ; // Grab properties of each person: CFStringRef firstName = ABRecordCopyValue(person, kABFirstNameProperty); CFStringRef lastName = ABRecordCopyValue(person, kABLastNameProperty); ABMultiValueRef address = ABRecordCopyValue(person, kABAddressProperty); ABMultiValueRef relatives = ABRecordCopyValue(person, kABRelatedNamesProperty); CFStringRef title = ABRecordCopyValue(person, kABTitleProperty); // Print properties of each person: printf("((first-name \"%s\")\n", cstring(firstName)) ; printf(" (last-name \"%s\")\n", cstring(lastName)) ; if (title) printf(" (title \"%s\")\n", cstring(title)) ; // There could be multiple address. // We have to loop through all of them. int numAddresses = ABMultiValueCount(address) ; for (int k = 0; k < numAddresses; ++k) { // Check the type: CFStringRef addrType = ABMultiValueCopyLabelAtIndex(address, k) ; // Keep searching if it's not the home address: if (CFStringCompare(addrType,kABHomeLabel,0) != 0) continue ; printf(" (address ") ; // Addresses have multiple keys: // Street, State, ZIP, Country, City CFDictionaryRef location = ABMultiValueCopyValueAtIndex(address, k) ; int size = CFDictionaryGetCount(location) ; CFStringRef keys[size] ; CFStringRef values[size] ; CFDictionaryGetKeysAndValues(location, (const void**)&keys, (const void**)&values) ; for (int a = 0; a < size; ++a) { if (a) printf(" ") ; printf("(%-12s \"%s\")", cstring(keys[a]), cstring(values[a])) ; if (a+1 == size) printf(")"); printf("\n"); } } // We have to search all relatives for the spouse/partner: int numRelatives = ABMultiValueCount(relatives) ; for (int j = 0; j < numRelatives; ++j) { CFStringRef relativeType = ABMultiValueCopyLabelAtIndex(relatives, j) ; CFStringRef relativeName = ABMultiValueCopyValueAtIndex(relatives, j) ; if (!CFStringCompare(relativeType,kABSpouseLabel,0)) printf(" (spouse \"%s\")\n", cstring(relativeName)) ; if (!CFStringCompare(relativeType,kABPartnerLabel,0)) printf(" (partner \"%s\")\n", cstring(relativeName)) ; } printf(" (uid %i))\n\n", uid++) ; } return EXIT_SUCCESS; }
To compile, use the
CoreFoundation
and AddressBook
frameworks:
% gcc -std=c99 -o abdump -framework CoreFoundation \ -framework AddressBook abdump.c
To run, supply abdump
with a group name:
% ./abdump "Holiday Card List"
The output format is human-readable, and should be self-explanatory. Each entry looks somewhat like the following:
((first-name "John") (last-name "Smith") (address (Street "100 Imaginary Drive") (State "XY") (ZIP "12345") (CountryCode "us") (Country "USA") (City "Metropolis")) (spouse "Jane Smith") (uid 83))
Formatting entries into mailing labels
To format the entries into mailing labels, I took advantage of Racket's pattern-matching capabilities.
For instance, it's easy to match on a record and check whether there's a spouse and whether that spouse shares a last name.
labelize.rkt converts each entry into a LaTeX-formatted mailing label:
#lang racket ; Labelize ; ; Author: Matthew Might ; Site: http://matt.might.net/ ; Original: http://matt.might.net/articles/address-book-hacking/ ; ; Converts an address book in S-Expression format ; into a .tex file suitable for printing on ; Avery {8162,5162} or {8160,5160} label layouts. ; Usage: ; ; % racket labelize.rkt [--paper=avery8160 | --paper=avery8162] [database.sx] ; Defaults to pulling in the file "entries.sx": (define args (current-command-line-arguments)) (define paper-format 'Avery-8160) (define file "entries.sx") (define (process-args args) (match args [(cons "--paper=avery8160" rest) (set! paper-format 'Avery-8160) (process-args rest)] [(cons "--paper=avery8162" rest) (set! paper-format 'Avery-8162) (process-args rest)] [(cons filename '()) (set! file filename)] ['() (void)])) (process-args (vector->list args)) (define p (open-input-file file)) ; Read in all entries: (define (read-all port) (let ((next (read port))) (cond [(eof-object? next) '()] [else (cons next (read-all port))]))) (define entries (read-all p)) (close-input-port p) ; Splits a name in two, e.g., "John Smith" => '("John" "Smith") (define (split-name name) (cond [(string? name) ; => (match (regexp-split #rx" +" name) [`(,first-name "de" "la" ,rest) ; => (list first-name (string-append "de" " " "la" " " rest))] [`(,first-name "de" ,rest) ; => (list first-name (string-append "de" " " rest))] [`(,first-name ,middle-name ,last-name) ; => (list (string-append first-name " " middle-name) last-name)] [as-matched as-matched])] [else '()])) ; Formats an entry: (define (format-name entry) (match entry ; Have a spouse? [(list-no-order `(first-name ,first-name) `(last-name ,last-name) `(spouse ,(app split-name `(,spouse-first ,spouse-last))) _ ...) ; => (cond ; Same last name: [(equal? spouse-last last-name) ; => (format "~a & ~a ~a" first-name spouse-first last-name)] ; Different last names: [else ; => (format "~a ~a & ~a ~a" first-name last-name spouse-first spouse-last)])] ; Partner: [(list-no-order `(first-name ,first-name) `(last-name ,last-name) `(partner ,partner-name) _ ...) ; => (format "~a ~a & ~a" first-name last-name partner-name)] ; Unmarried, with title: [(list-no-order `(first-name ,first-name) `(last-name ,last-name) `(title ,title) _ ...) ; => (format "~a ~a ~a" title first-name last-name)] ; Unmarried, no title: [(list-no-order `(first-name ,first-name) `(last-name ,last-name) _ ...) ; => (format "~a ~a" first-name last-name)])) ; Format address: (define (format-address entry) (match entry ; Foreign address? [(list-no-order `(address . ,(list-no-order `(Street ,street) `(City ,city) `(State ,state) `(ZIP ,zip) `(Country ,(and country (not (or "USA" "United States")))) _ ...)) _ ...) ; => (format "~a~n~a, ~a ~a ~a" street city state zip country)] ; U.S. address? [(list-no-order `(address . ,(list-no-order `(Street ,street) `(City ,city) `(State ,state) `(ZIP ,zip) _ ...)) _ ...) ; => (format "~a~n~a, ~a ~a" street city state zip)] [else (format "Insufficient address info: ~a" entry)])) ; Format an entry into label: (define (format-label entry) (format "~a~n~a" (format-name entry) (format-address entry))) ; Escape special characters in LaTeX: (define (latex-escape string) (let* ([s string] [s (regexp-replace* #rx"#" s "\\\\#")] [s (regexp-replace* #rx"&" s "\\\\&")] [s (regexp-replace* #rx"[\n ]*\n[\n ]*" s "\\\\\\\\\n")]) s)) (when (eq? paper-format 'Avery-8162) ; LaTeX preamble for 8162, 2 x 14: (display "\\documentclass[letterpaper,12pt]{report} \\usepackage{palatino} \\pagestyle{empty} \\oddsidemargin -0.80in \\evensidemargin -0.80in \\marginparwidth 0pt \\marginparsep 0pt \\topmargin -0.00in \\headsep 0pt \\headheight 0pt \\textheight 10.5in \\textwidth 8in \\raggedbottom \\parindent=0in \\begin{document}\n") (define entry-count 0) ; Print out all the entries: (for ([entry entries]) ; Each adress is a mini-page: (display "\\begin{minipage}[t][1.33in]{4in}\n\\centering\n") (display (latex-escape (format-label entry))) (newline) (display "\\end{minipage}\n") (if (= (modulo entry-count 2) 1) (display "\\\\\n") (display "\\hspace{0.10in}\n")) (display "%") (newline) (set! entry-count (+ 1 entry-count)) (when (= (modulo entry-count 14) 0) (display "\\newpage"))) (display "\\end{document}\n")) (when (eq? paper-format 'Avery-8160) ; LaTeX preamble for 8160, 3 x 10: (display "\\documentclass[letterpaper,12pt]{report} \\usepackage{palatino} \\pagestyle{empty} \\oddsidemargin -0.80in \\evensidemargin -0.80in \\marginparwidth 0pt \\marginparsep 0pt \\topmargin -0.40in \\headsep 0pt \\headheight 0pt \\textheight 10.5in \\textwidth 8in \\raggedbottom \\parindent=0in \\begin{document}\n") (define entry-count 0) ; Print out all the entries: (for ([entry entries]) ; Each adress is a mini-page: (display "\\begin{minipage}[t][0.98in]{2.5in}\n\\centering\n") (display (latex-escape (format-label entry))) (newline) (display "\\end{minipage}\n") (if (= (modulo entry-count 3) 2) (display "\\\\\n") (display "\\hspace{0.15in}\n")) (display "%") (newline) (set! entry-count (+ 1 entry-count)) (when (= (modulo entry-count 30) 0) (display "\\newpage"))) (display "\\end{document}\n"))
It prints 30 addresses per sheet. It should be easy to tweak the dimensions and spacing to match any label printing standard.
Printing mailing labels
To print the labels, use Avery label paper: