A Better Birthdays Calendar

OS X, since 10.4, has an option to automatically maintain a birthday calendar in iCal showing all of your contacts’ birthdays. Unfortunately, turning it on is its only configurable option. This is unfortunate because I personally need an audible or visual alert telling me of all the days’ birthdays. Also, this automatic calendar is a local calendar, meaning that we can’t use it with Exchange, MobileMe, CalDAV or any other over-the-air calendar syncing technology. So let’s replace it…

I’ve written a solution for Snow Leopard that satisfies the following goals:

  1. Enables alarms for every birthday so I don’t have to check iCal daily.
  2. Allows birthday events to be stored in a non-local calendar, so Exchange, CalDAV and MobileMe users don’t need to sync to iTunes to get this data.
  3. Maintains Apple’s feature of allowing an event to hyperlink to its relevant Address Book card (Only works on the Mac).
  4. Automatically updates itself without requiring me to periodically launch an application or keep an application running to monitor for changes.

You can download the necessary AppleScript file and launchd plist here. You will need to make a few changes to each of them.

The AppleScript

property calendarName : "Birthdays"
property alarmLength : 10 * 60
property alarmSound : "Basso"

on run (options)
    tell application "iCal"
        set possibleCalendars to (get every calendar whose name is calendarName and writable is true)
        if (count possibleCalendars) ≠ 1 then
            set errorMessage to "Please ensure there is at least one, and only one, writable calendar named “" & calendarName & "”"
            tell me
                log errorMessage
                if options does not contain "silent" then
                    display alert "Alert" message errorMessage as critical buttons "Cancel" default button 1
                end if
            end tell
            return
        end if
        set birthdayCalendar to item 1 of possibleCalendars
    end tell

    tell application "Address Book"
        set birthdayPeople to (get every person whose birth date is greater than date "Monday, January 1, 1900 12:00:00 AM")
        set theCountTotal to count birthdayPeople
        set theCountCreated to 0
        set theCountUpdated to 0
        tell me to log (theCountTotal as text) & " birthdays are stored in Address Book."
        repeat with birthdayPerson in birthdayPeople
            tell birthdayPerson
                if first name is not "" and last name is not "" then
                    set eventTitle to first name & " " & last name & "'s Birthday"
                else
                    set eventTitle to first name & last name & "'s Birthday"
                end if
                set eventURL to "addressbook://" & id
                set eventDateStart to birth date - 12 * hours
                set eventDateEnd to birth date + 12 * hours
            end tell
            tell application "iCal"
                tell birthdayCalendar
                    try
                        set existingEvent to (first event whose url = eventURL)
                        tell existingEvent
                            if summary ≠ eventTitle or allday event ≠ true or url ≠ eventURL or recurrence ≠ "FREQ=YEARLY;INTERVAL=1" or start date ≠ eventDateStart or end date ≠ eventDateEnd then
                                set summary to eventTitle
                                set allday event to true
                                set url to eventURL
                                set recurrence to "FREQ=YEARLY;INTERVAL=1"
                                set start date to eventDateStart
                                set end date to eventDateEnd
                                tell me to log eventTitle & " was updated."
                                set theCountUpdated to theCountUpdated + 1
                            else
                                tell me to log eventTitle & " needs no update."
                            end if
                            if (count of (every sound alarm whose trigger interval = alarmLength and sound name = alarmSound)) = 0 then
                                make new sound alarm at end of sound alarms with properties {trigger interval:alarmLength, sound name:alarmSound}
                                return result
                            end if
                            delete (every sound alarm whose trigger interval ≠ alarmLength or sound name ≠ alarmSound)
                        end tell
                    on error msg number num
                        if num = -1719 then
                            set newEvent to make new event at end of events with properties {summary:eventTitle, allday event:true, url:eventURL, recurrence:"FREQ=YEARLY;INTERVAL=1", start date:eventDateStart, end date:eventDateEnd}
                            tell newEvent
                                make new sound alarm at end of sound alarms with properties {trigger interval:alarmLength, sound name:alarmSound}
                            end tell
                            tell me to log eventTitle & " was created."
                            set theCountCreated to theCountCreated + 1
                        else
                            error msg number num
                        end if
                    end try
                end tell
            end tell
        end repeat
        tell me
            log (theCountCreated as text) & " birthdays were created."
            log (theCountUpdated as text) & " birthdays were updated."
            if (options does not contain "silent") then
                set summaryReport to (theCountCreated as text) & " birthdays were created." & return & (theCountUpdated as text) & " birthdays were updated."
                display alert "Update complete" message summaryReport as informational buttons "OK" default button 1
            end if
        end tell
    end tell
end run

The AppleScript opens up with a few properties. Please changes these to your liking. Note the format of alarmLength must be the amount of minutes before or after midnight at the start of the birthday. I like my alarms to notify me at 10 am on the day of the birthday, so my value is 10 * 60 (10 hours after the birthday). You might want to set this to something before the birthday so you have some notice. For example, if you want the alarm to be at 10 am the day before the birthday, set it to -14 * 60 (14 hours before the birthday).

Once you have saved your changes, I recommend putting the AppleScript file in ~/Library/Scripts/Applications/Address Book/. That way, you can manually run it from the AppleScript menu while in Address Book, a seemingly logical location.

The launchd plist

Launchd is a process in OS X that manages the launching of virtually all other process. It also is meant to replace numerous system process, including of specific relevance to us at this moment, cron. Using launchd, we can set our AppleScript to run periodically to update our birthday calendar.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.thepracticeofcode.birthdays</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/osascript</string>
        <string>/Users/jay/Library/Scripts/Applications/Address Book/Update Birthdays.scpt</string>
        <string>silent</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StartInterval</key>
    <integer>86400</integer>
    <key>StandardErrorPath</key>
    <string>/Users/jay/Library/Logs/birthdaysync.log</string>
    <key>StandardOutPath</key>
    <string>/Users/jay/Library/Logs/birthdaysync.log</string>
</dict>
</plist>

You will need to update the full path to the AppleScript to include your own short name. Unfortunately, I don’t know of any generic placeholder for launchd to automatically use the current user’s short name. Anyone? Also note that in launchd plists, you do not need to escape spaces in paths.

Set the StartInterval to your own preference. This is a value in seconds. The current value of 86400 equals 24 hours. Feel free to increase or decrease to your liking.

Also, logging is optional. I like to see these kinds of things, but it’s up to you. Again, make sure to update the paths for StandardErrorPath and StandardOutPath to contain the correct short name and desired log file name.

Lastly, store the file in your Home folder -> Library -> LaunchAgents. You might need to create that folder.

Loading the script

The script will automatically load the next time you login, but if you want to get the ball rolling now, execute this command in Terminal:

launchctl load ~/Library/LaunchAgents/com.thepracticeofcode.birthdays.plist

The script should run right away on load due to the RunAtLoad directive. This also means it will run as the job loads at login each time as well. Change <true/> to <false/> if that’s not desirable.

To unload the job, simply use the slightly different command:

launchctl unload ~/Library/LaunchAgents/com.thepracticeofcode.birthdays.plist

To restart it after making changes, simply unload it and load it again.

That’s it!

Let me know if you have any questions, comments or trouble getting it to work.

Friday, January 29, 2010 — 5 notes   ()
  1. practiceofcode posted this