Dump Calendar.app events to JSON
I wanted a way to dump Calendar.app events to JSON. I came across icalBuddy, which can dump your events to text. I threw together the following bash/jq script to convert icalBuddy output to JSON.
First, you need to install icalBuddy and jq. With Homebrew:
brew install ical-buddy jq
Then copy this script somewhere on your $PATH
, perhaps
/usr/local/bin/ical-buddy-json
:
#!/usr/bin/env bash
# Usage: ical-buddy-json [-w | -d] [-c CALENDAR1[,CALENDAR2...]] [date]
#
# NAME
# ical-buddy-json -- dumps Apple Calendar data to JSON via iCalBuddy and jq
#
# SYNOPSIS
# ical-buddy-json [-w | -d] [-c CALENDAR1[,CALENDAR2...]] [date]
#
# DESCRIPTION
# Dumps Apple Calendar data to JSON via iCalBuddy and jq.
#
# OPTIONS
# -w, --weekly
# If set, show events for the entire week that the specified date falls
# within. This is the default behavior.
#
# -d, --daily
# If set, limit output to just the events for the specified date.
#
# -c [CALENDAR1[,CALENDAR2...]], --calendars [CALENDAR1[,CALENDAR2...]]
# Comma separated list of calendars to check. If set, this take precedence
# over any default calendars set in iCalBuddy's config file at
# `~/.icalBuddyConfig.plist`.
#
# -g, --group-by-date
# If set, return a JSON object with dates as keys (i.e.
# `{"DATE" => [{}, {}], ...}`).
#
# -r, --raw
# Show raw iCalBuddy output instead of converting to JSON. Probably only
# useful for debugging this script.
#
# EXAMPLES
# ical-buddy-json -w 2021-09-12
# ical-buddy-json -d 2021-09-12
#
# SEE ALSO
# ICALBUDDY(1), JQ(1)
# Call this script with DEBUG=1 to add some debugging output
if [[ "$DEBUG" ]]; then
export PS4='+ [${BASH_SOURCE##*/}:${LINENO}] '
set -x
fi
set -e
# Character used to separate properies iCalBuddy output
ICBPS="#ICALBUDDY-PROPERTY-SEPARATOR#"
# Character used for new lines in iCalBuddy notes output
ICBNL="#ICALBUDDY-NEW-LINE#"
# Character used for separating dates in iCalBuddy output
ICBSS="#ICALBUDDY-SECTION-SEPARATOR#"
# Echoes given args to STDERR
#
# $@ - args to pass to echo
warn() {
echo "$@" >&2
}
# Reformats the given date
#
# $1 - source date (default in %Y-%m-%d format)
# $2 - new format to output (not including the +, default %Y-%m-%d)
# $3 - source date format (default is %Y-%m-%d)
reformat_date() {
local now="$1" new_fmt="${2-%F}" src_fmt="${3-%F}"
date -j -f "$src_fmt" "$now" "+$new_fmt"
}
# Returns midnight Sunday for the given week
#
# $1 - date in %Y-%m-%d format
start_of_week() {
local now="$1" offset dow seconds
dow="$(reformat_date "$now" "%w")"
seconds="$(reformat_date "$now" "%s")"
offset="$(days_to_seconds "$dow")"
date -r "$(( seconds - offset ))" "+%Y-%m-%d 00:00:00 %z"
}
# Returns 11:59:59pm Saturday for the given week
#
# $1 - date in %Y-%m-%d format
end_of_week() {
local now="$1" offset dow seconds
dow="$(reformat_date "$now" "%w")"
seconds="$(reformat_date "$now" "%s")"
offset="$(days_to_seconds "$(( 6 - dow ))")"
date -r "$(( seconds + offset ))" "+%Y-%m-%d 23:59:59 %z"
}
# Returns midnight for the given day
#
# $1 - date in %Y-%m-%d format
start_of_day() {
local now="$1"
reformat_date "$now" "%Y-%m-%d 00:00:00 %z"
}
# Returns 11:59:59pm for the given day
#
# $1 - date in %Y-%m-%d format
end_of_day() {
local now="$1"
reformat_date "$now" "%Y-%m-%d 23:59:59 %z"
}
# Calculates the number of seconds in days
#
# $1 - number of days
days_to_seconds() {
local days="$1"
echo "$(( days * 60 * 60 * 24 ))"
}
# Minutes between the two given times
#
# $1 - start time HH:MM
# $2 - end time HH:MM
minutes_between() {
local start_at="$1" end_at="$2" start_secs end_secs
start_secs="$(reformat_date "$start_at:00" "%s" "%T")"
end_secs="$(reformat_date "$end_at:00" "%s" "%T")"
echo "$(( (end_secs - start_secs) / 60 ))"
}
# TODO: should we grab earlier events and filter them out? Right now, if a
# multi-day event starts before end_at it is not included in our output when
# it (arguably) should be.
#
# Main wrapper for iCalBuddy.
#
# $1 - datetime to start at (in `%Y-%m-%d %H:%M:%S` format)
# $2 - datetime to end at (in `%Y-%m-%d %H:%M:%S` format)
# $3 - limit events to the specified calendars
fetch_events() {
local -a opts=()
local start_at="$1" end_at="$2" calendars="${3-}"
if [[ "$calendars" ]]; then
opts+=("--includeCals" "$calendars")
fi
opts+=(
"--separateByDate"
"--showEmptyDates"
"--sectionSeparator" "$ICBSS"
"--noRelativeDates"
"--dateFormat" "%Y-%m-%d"
"--timeFormat" "%H:%M"
"--bullet" ""
"--propertySeparators" "|$ICBPS|"
"--includeEventProps" "title,datetime,notes"
"--propertyOrder" "title,datetime,notes"
"--noPropNames"
"--notesNewlineReplacement" "$ICBNL"
"eventsFrom:$start_at"
"to:$end_at"
)
if ! iCalBuddy "${opts[@]}" 2> /dev/null; then
warn "Error getting events!"
return 1
fi
}
# Parse output from iCalBuddy
#
# $1 - if set, group output by date
parse_events() {
local group_by_date="${1-}" current_date line
# Main parse loop...
while read -r line; do
local raw_title="" raw_time="" raw_notes="" title="" calendar="" \
start_at="" end_at="" duration="" notes=""
# Empty line... skip
if [[ -z "$line" ]]; then
continue
# New date found
# YYYY-MM-DD:$ICBSS
elif [[ "${line: -${#ICBSS}}" = "$ICBSS" ]]; then
current_date="${line%:*}"
continue
# No events on $current_date
elif [[ "$line" = "Nothing." ]]; then
continue
# We have a timestamp and notes
# test2##ICBPS##20:30 - 21:30##ICBPS##notes all day2
elif [[ "$line" = *"$ICBPS"*"$ICBPS"* ]]; then
raw_title="${line%%${ICBPS}*}"
raw_time="${line#*$ICBPS}"
raw_time="${raw_time%$ICBPS*}"
raw_notes="${line##*$ICBPS}"
# We have a timestamp but no notes
# newnew##ICBPS##18:45 - 19:45
elif [[ "$line" =~ ${ICBPS}[0-9]{2}:[0-9]{2}\ -\ [0-9]{2}:[0-9]{2}$ ]]; then
raw_title="${line%%${ICBPS}*}"
raw_time="${line##*$ICBPS}"
raw_notes=""
# All day event with notes
# newnew##ICBPS##notes!
elif [[ "$line" = *"$ICBPS"* ]]; then
raw_title="${line%%${ICBPS}*}"
raw_time=""
raw_notes="${line##*$ICBPS}"
# All day event, no notes
else
raw_title="$line"
raw_time=""
raw_notes=""
fi
# If there isn't at least a title at this point, skip.
if [[ -z "$raw_title" ]]; then
continue
fi
title="${raw_title% (*}"
calendar="${raw_title##*(}"
calendar="${calendar%%)}"
# HH:MM - HH:MM
if [[ "$raw_time" =~ ^[012][0-9]:[0-5][0-9]\ -\ [012][0-9]:[0-5][0-9]$ ]]; then
start_at="${raw_time% - *}"
end_at="${raw_time#* - }"
duration="$(minutes_between "$start_at" "$end_at")"
start_at="$(reformat_date "$start_at:00" "%I:%M%p" "%T" | tr "APM" "apm")"
end_at="$(reformat_date "$end_at:00" "%I:%M%p" "%T" | tr "APM" "apm")"
fi
notes="${raw_notes//$ICBNL/$'\n'}"
format_row \
"$title" \
"$calendar" \
"$current_date" \
"$notes" \
"$start_at" \
"$end_at" \
"$duration"
done | format_collection "$group_by_date"
}
# Format a single event as a JSON object with jq
#
# $1 - event title
# $2 - event calendar name
# $3 - event date (YYYY-MM-DD)
# $4 - event notes
# $5 - event start time (HH:MMam)
# $6 - event end time (HH:MMam)
# $6 - event duration (M)
format_row() {
local title="$1" \
calendar="$2" \
date="$3" \
notes="$4" \
start_at="$5" \
end_at="$6" \
duration="$7"
jq --null-input \
--arg title "$title" \
--arg calendar "$calendar" \
--arg date "$date" \
--arg notes "$notes" \
--arg start_at "$start_at" \
--arg end_at "$end_at" \
--arg duration "$duration" \
'
{
$title,
$calendar,
$date,
$notes,
start_at: (if $start_at == "" then null else $start_at end),
end_at: (if $end_at == "" then null else $end_at end),
duration: (if $duration == "" then 0 else $duration end) | tonumber,
urls: $notes | [
match("https?://[a-zA-Z0-9~#%&_+=,.?/-]+"; "g") | .string
] | unique
}'
}
# Format the collection of events with jq. If group_by_date is set, outputs an
# object with dates as keys (i.e. `{"DATE" => [{}, {}], ...}`), otherwise
# outputs an array of objects (i.e. `[{}, {}...]`)
#
# $1 - if set, group events by date
format_collection() {
local group_by_date="$1"
if [[ "$group_by_date" ]]; then
jq --slurp 'reduce .[] as $e (null; .[$e.date] += [$e])'
else
jq --slurp
fi
}
# Print the help text for this program
#
# $1 - flag used to ask for help ("-h" or "--help")
print_help() {
sed -ne '/^#/!q;s/^#$/# /;/^# /s/^# //p' < "$0" |
awk -v f="$1" '
f == "-h" && ($1 == "Usage:" || u) {
u=1
if ($0 == "") {
exit
} else {
print
}
}
f != "-h"
'
}
main() {
local start_at end_at calendars target_date mode=weekly raw group_by_date
while [[ $# -gt 0 ]]; do
case "$1" in
-g | --group-by-date) group_by_date=1; shift;;
-w | --weekly) mode=weekly; shift ;;
-d | --daily) mode=daily; shift ;;
-r | --raw) raw=1; shift ;;
-h | --help) print_help "$1"; return 0 ;;
-c | --calendars) calendars="$2"; shift 2 ;;
--) shift; break ;;
-*) warn "Invalid option '$1'"; return 1 ;;
*) break ;;
esac
done
if ! type icalBuddy &> /dev/null; then
warn "icalBuddy missing!"
return 1
elif ! type jq &> /dev/null; then
warn "jq missing!"
return 1
fi
target_date="${1:-$(date "+%Y-%m-%d")}"
case "$mode" in
weekly)
start_at="$(start_of_week "$target_date")"
end_at="$(end_of_week "$target_date")"
;;
daily)
start_at="$(start_of_day "$target_date")"
end_at="$(end_of_day "$target_date")"
;;
esac
if ! events="$(fetch_events "$start_at" "$end_at" "$calendars")"; then
return 1
elif [[ "$raw" ]]; then
printf "%s" "$events"
else
parse_events "$group_by_date" <<< "$events" 2>&1 |
jq '.' # Make sure we can parase the JSON we've constructed...
fi
}
main "$@"
To configure which calendars show up, create ~/.icalBuddyConfig.plist
:
<?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>constantArguments</key>
<dict>
<key>includeCals</key>
<string>Main,DNS</string>
</dict>
</dict>
</plist>
The JSON output looks like:
[
{
"title": "All Day Event",
"calendar": "Main",
"date": "2022-01-28",
"notes": "",
"start_at": null,
"end_at": null,
"duration": 0,
"urls": []
}
{
"title": "Very Important Call",
"calendar": "Main",
"date": "2022-01-28",
"notes": "<br/><br/>Google Meet: https://meet.google.com/xxx-xxxx-xxx<br/>Phone: +1 999-999-9999, PIN: 999999999\n\n-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-\nDo not edit this section of the description.\n\nThis event has a video call.\nJoin: https://meet.google.com/xxx-xxxx-xxx\n(US) +1 999-999-9999 PIN: 999999999#\n-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-",
"start_at": "02:00pm",
"end_at": "03:00pm",
"duration": 60,
"urls": [
"https://meet.google.com/xxx-xxxx-xxx"
]
},
{
"title": "Weekly Planning",
"calendar": "DNS",
"date": "2022-01-28",
"notes": "",
"start_at": "04:00pm",
"end_at": "05:00pm",
"duration": 60,
"urls": []
}
]