iCal daily event summary

I really like the way Google Calendar can email a summary of the upcoming day’s events so have been trying to produce a similar process for iCal using AppleScript. The difficult part for me (who is pretty new to this) is getting the events, including recurring ones. I have been trying Nigel Garvey’s script, as follows:

(* The vanilla code in this script is AS 1.9.1 compatible. *)

(* Oversee the calculation of the next due recurrence date (if any) of an iCal
event, given its recurrence value, start date, and the date of the check. Return
the recurrence date or, if the event's expired, missing value. *)
on getNextRecurrence(RFC2445, startdate, today)
	-- copy startDate to startDate
	-- copy today to today
	-- Or, as here, take the times of day out of the process.
	set startdate to startdate - (startdate's time)
	set today to today - (today's time)
	
	set nextRecurrence to missing value -- In case we don't find anything.
	if (startdate comes before today) then -- The start date's in the past.
		if ((count RFC2445) > 0) then -- This is a recurring event.
			considering case
				set endDate to getEndDate(RFC2445) + (startdate's time)
				if (endDate does not come before today) then -- Not yet expired.
					set interval to getInterval(RFC2445)
					set maxRecurrences to getMaxRecurrences(RFC2445)
					set freq to getRule(RFC2445, "FREQ")
					if (freq is "DAILY") then
						set nextRecurrence to nextDaily(startdate, today, endDate, interval, maxRecurrences)
					else if (freq is "WEEKLY") then
						set nextRecurrence to nextWeekly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
					else if (freq is "MONTHLY") then
						set nextRecurrence to nextMonthly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
					else if (freq is "YEARLY") then
						set nextRecurrence to nextYearly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
					end if
				end if
			end considering
		end if
	else -- A start date in the present or future is any event's (recurring or not) "next recurrence".
		set nextRecurrence to startdate
	end if
	
	return nextRecurrence
end getNextRecurrence

(* Get the next due recurrence date of a "DAILY" event. (Usually today.) *)
on nextDaily(startdate, today, endDate, interval, maxRecurrences)
	set interval to interval * days
	set nextRecurrence to today - (today - 1 - startdate) mod interval + interval - 1
	if ((nextRecurrence - startdate) div interval + 1 > maxRecurrences) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextDaily

(* Get the next due recurrence date of a "WEEKLY" event. *)
on nextWeekly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
	set BYDAYspecified to (RFC2445 contains "BYDAY")
	if (BYDAYspecified) then
		-- Get the offsets (in seconds) of the specified week-start weekday from Sunday and the recurrence weekdays from that.
		set {WKSToffset, weekdayOffsets} to getWeekdayInstanceStuff(RFC2445)
		-- Work out the start date of the week that starts on the WKST weekday and contains the event's start date.
		set weekStart to startdate - (startdate - ((«data isot313030302D30312D3035» as date) + (startdate's time) + WKSToffset)) mod weeks
	else
		-- Otherwise, regard the event's start date as the week start and use an offset of 0.
		set weekStart to startdate
		set weekdayOffsets to {0}
	end if
	set recurrencesPerWeek to (count weekdayOffsets)
	set interval to interval * weeks
	
	-- Run the recurrence sequence from the week of the start date, starting with the first weekday in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first recurrence on or after today, or until maxRecurrences is exceeded.
	set nextRecurrence to startdate
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set i to 0 -- Index into weekdayOffsets.
	repeat while (nextRecurrence comes before today) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerWeek) then
			set i to 1
			set weekStart to weekStart + interval
		end if
		set nextRecurrence to weekStart + (item i of weekdayOffsets)
		if (nextRecurrence comes after startdate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before today) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextWeekly

(* Get the next due recurrence date of a "MONTHLY" event. *)
on nextMonthly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
	set BYDAYspecified to (RFC2445 contains "BYDAY=")
	if (BYDAYspecified) then
		set {weekdayCode, weekdayInstanceNos} to getWeekdayInstanceStuff(RFC2445)
		set recurrencesPerMonth to (count weekdayInstanceNos)
	else
		set dayNumbers to getTargetDays(RFC2445, startdate)
		set recurrencesPerMonth to (count dayNumbers)
	end if
	
	-- Run the recurrence sequence from the month of the start date, starting with the first recurrence day or weekday in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first valid occurrence on or after today, or until maxRecurrences is exceeded.
	copy startdate to nextRecurrence
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set dayInMonth to true -- Changed to false when a recurrence day doesn't exist in the month.
	set i to 0 -- Index into weekdayInstanceNos or dayNumbers.
	repeat while ((nextRecurrence comes before today) or not (dayInMonth)) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerMonth) then
			set i to 1
			set nextRecurrence to addMonths(nextRecurrence, interval)
		end if
		if (BYDAYspecified) then
			-- Get the date of the specified weekday in this month.
			set nextRecurrence to getWeekdayDate(nextRecurrence, weekdayCode, item i of weekdayInstanceNos)
			set dayInMonth to true
		else
			-- Get the date of the (next) specified numbered day in this month.
			set nextRecurrence's day to item i of dayNumbers
			-- If the day doesn't exist in the month, the day and month will now have changed.
			set dayInMonth to (nextRecurrence's day is result)
			if not (dayInMonth) then set nextRecurrence to nextRecurrence - weeks -- Correct the month.
		end if
		if (dayInMonth) and (nextRecurrence comes after startdate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before today) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextMonthly

(* Get the next due recurrence date of a "YEARLY" event. *)
on nextYearly(RFC2445, startdate, today, endDate, interval, maxRecurrences)
	set targetMonths to getTargetMonths(RFC2445, startdate)
	set recurrencesPerYear to (count targetMonths)
	set BYDAYspecified to (RFC2445 contains "BYDAY=")
	if (BYDAYspecified) then set {weekdayCode, weekdayInstanceNos} to getWeekdayInstanceStuff(RFC2445)
	
	-- Run the recurrence sequence from the year of the start date, starting with the relevant day of the first month in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first occurrence on or after today, or until maxRecurrences is exceeded.
	copy startdate to nextRecurrence
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set defaultDayNo to startdate's day
	set dayInMonth to true -- Set to false when a recurrence day doesn't exist in a target month.
	set i to 0 -- Index into targetMonths.
	repeat while ((nextRecurrence comes before today) or not (dayInMonth)) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerYear) then
			set i to 1
			set nextRecurrence's year to (nextRecurrence's year) + interval
		end if
		set targetMonth to item i of targetMonths -- Get the next specified month.
		if (BYDAYspecified) then
			-- Get the date of the specified weekday in this month of this year.
			set nextRecurrence's day to 1
			set nextRecurrence's month to targetMonth
			set nextRecurrence to getWeekdayDate(nextRecurrence, weekdayCode, beginning of weekdayInstanceNos)
		else
			-- Get the date of the specified numbered day in this month of this year.
			set nextRecurrence's day to defaultDayNo
			set nextRecurrence's month to targetMonth
		end if
		-- If the day doesn't exist in the month, the day and month will now have changed.
		set dayInMonth to (nextRecurrence's month is targetMonth)
		if (dayInMonth) and (nextRecurrence comes after startdate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before today) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextYearly

(* Odd jobs. *)

(* Read a given rule from the event's 'recurrence' text. *)
on getRule(RFC2445, ruleKey)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ruleKey & "="
	set rule to text item 2 of RFC2445
	set AppleScript's text item delimiters to ";"
	set rule to text item 1 of rule
	set AppleScript's text item delimiters to astid
	
	return rule
end getRule

(* Get the recurrence's "UNTIL" date (if specified) in AppleScript form or default to 31st December 9999. (Ignore time.) *)
on getEndDate(RFC2445)
	set endDate to «data isot393939392D31322D3331» as date -- 31st December 9999.
	if (RFC2445 contains "UNTIL") then
		set n to (text 1 thru 8 of getRule(RFC2445, "UNTIL")) as integer
		set endDate's day to n mod 100
		set endDate's year to n div 10000
		set endDate's month to item (n mod 10000 div 100) of {January, February, March, April, May, June, July, August, September, October, November, December}
	end if
	
	return endDate -- +- (time to GMT)?
end getEndDate

(* Get the event's recurrence interval (if specified) or default to 1. *)
on getInterval(RFC2445)
	if (RFC2445 contains "INTERVAL") then return getRule(RFC2445, "INTERVAL") as integer
	return 1
end getInterval

(* Get the event's recurrence count (if specified) or default to an arbitrarily high number. *)
on getMaxRecurrences(RFC2445)
	if ((RFC2445 contains "COUNT") and (RFC2445 does not contain "COUNT=-1")) then return getRule(RFC2445, "COUNT") as integer
	return 1000000
end getMaxRecurrences

(* Return a list of the text items in a comma-delimited rule result. *)
on getListFromRule(rule)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ","
	set theList to rule's text items
	set AppleScript's text item delimiters to astid
	
	return theList
end getListFromRule

(* Derive a list of AppleScript months from the numbers in a "BYMONTH" rule or default to the month of the event's start date. *)
on getTargetMonths(RFC2445, startdate)
	if (RFC2445 contains "BYMONTH=") then
		set targetMonths to getListFromRule(getRule(RFC2445, "BYMONTH"))
		set monthList to {January, February, March, April, May, June, July, August, September, October, November, December}
		repeat with thisMonth in targetMonths
			set thisMonth's contents to item (thisMonth as integer) of monthList
		end repeat
	else
		set targetMonths to {startdate's month}
	end if
	
	return targetMonths
end getTargetMonths

(* Get a list of the day numbers specified in a "BYMONTHDAY" rule or default to the day of the event's start date. *)
on getTargetDays(RFC2445, startdate)
	if (RFC2445 contains "BYMONTHDAY=") then
		set targetDays to getListFromRule(getRule(RFC2445, "BYMONTHDAY"))
		repeat with thisDay in targetDays
			set thisDay's contents to thisDay as integer
		end repeat
	else
		set targetDays to {startdate's day}
	end if
	
	return targetDays
end getTargetDays

(* Get and analyse the weekday(s) specified in a "BYDAY" rule, returning appropriate results. *)
on getWeekdayInstanceStuff(RFC2445)
	set BYDAY to getRule(RFC2445, "BYDAY")
	if (RFC2445 contains "WEEKLY") then
		-- In a "WEEKLY" environment, return a list of offsets, in seconds, of the specified weekdays from the specified week start.
		-- The week start itself is returned as an offset in seconds from Sunday.
		set weekdayCodes to "SUMOTUWETHFRSA"
		set WKSToffset to (offset of getRule(RFC2445, "WKST") in weekdayCodes) div 2 * days
		set weekdayOffsets to getListFromRule(BYDAY)
		repeat with thisEntry in weekdayOffsets
			-- Each weekday offset must be the offset AFTER or including the week start.
			set thisEntry's contents to ((offset of thisEntry in weekdayCodes) div 2 * days + weeks - WKSToffset) mod weeks
		end repeat
		
		return {WKSToffset, weekdayOffsets}
	else
		-- In a "MONTHLY" environment, only one weekday is specified, along with an instance-in-month figure.
		-- The standard allows for the lack of an instance number to mean "every instance in the month".
		-- iCal doesn't currently implement this, but there's a hook for it in the script.  :)
		set weekdayCode to text -2 thru -1 of BYDAY
		if ((count BYDAY) > 2) then
			set instanceNos to {(text 1 thru -3 of BYDAY) as integer}
		else
			set instanceNos to {1, 2, 3, 4, -1}
		end if
		
		return {weekdayCode, instanceNos}
	end if
end getWeekdayInstanceStuff

(* Get a date that's m calendar months after (or before, if m is negative) the input date. *)
to addMonths(oldDate, m)
	copy oldDate to newDate
	set {y, m} to {m div 12, m mod 12}
	set newDate's year to (newDate's year) + y
	-- Add the odd months (at 32 days per month) and set the day.
	if (m is not 0) then tell newDate to set {day, day} to {32 * m, day}
	-- If the day's changed, the original doesn't exist in the target month.
	-- Subtract the overflow into the following month to return to the last day of the target month.
	if (newDate's day is not oldDate's day) then set newDate to newDate - (newDate's day) * days
	
	return newDate
end addMonths

(* Get the date of a given instance of a given weekday in the month of a given date. *)
on getWeekdayDate(givenDate, weekdayCode, instanceNo) -- (AS date, 2-letter BYDAY code, integer)
	-- Get a date in the past that's known to have the required weekday. (Sunday 5th January 1000 + 0 to 6 days.)
	set refDate to («data isot313030302D30312D3035» as date) + (givenDate's time) + (offset of weekdayCode in "SUMOTUWETHFRSA") div 2 * days
	-- Get the last day of the seven-day period in the current month that includes the given instance of any weekday.
	if (instanceNo is -1) then
		tell givenDate to tell it + (32 - (its day)) * days to set periodEnd to it - (its day) * days -- Last day of month.
	else
		copy givenDate to periodEnd
		set periodEnd's day to instanceNo * 7 -- 7th, 14th, 21st, or 28th of month.
	end if
	
	-- Round down to an exact number of weeks after the known-weekday date.  
	return periodEnd - (periodEnd - refDate) mod weeks
end getWeekdayDate

tell application "iCal"
	set {allEvents, allSummaries, allStartDates, allRecurrenceTexts} to {it, summary, start date, recurrence} of events of calendar "Zarafa Calendar"
end tell
set today to (current date)
set todaysSummaries to {}

repeat with i from 1 to (count allSummaries)
	set nextRecurrence to my getNextRecurrence(item i of allRecurrenceTexts, item i of allStartDates, today)
	if (nextRecurrence is today) then set end of todaysSummaries to item i of allSummaries
end repeat
if (todaysSummaries is {}) then
	display dialog "Nothing to do today." with icon note
else
	choose from list todaysSummaries with prompt "Here's what's happening today."
end if

However, when I run the above, I get an error:

“Can’t get text item 2 of "FREQ=WEEKLY;INTERVAL=1;BYDAY=WE".”

This seems to be set in the “Odd jobs” section, but I’ve no idea why it’s happening.

I’d appreciate any feedback.

Thanks,

Des Dougan

Hi, Des. Welcome to MacScripter.net.

The WEEKLY recurrence rule quoted in your error message doesn’t have a WKST component. (This defines the weekday on which a “week” starts.) The error’s probably occurring when the script tries to extract this value. That’s my mistake. The script shouldn’t assume that WKST is there, but should default to Monday week starts if it isn’t.

In the getWeekdayInstanceStuff(RFC2445) handler, there’s a line:

set WKSToffset to (offset of getRule(RFC2445, "WKST") in weekdayCodes) div 2 * days

If you replace it with the following, the script should behave correctly. Sorry about that. I’ll revisit my “Code Exchange” posts at more leisure later today.

if (RFC2445 contains "WKST=") then
	set WKSToffset to (offset of getRule(RFC2445, "WKST") in weekdayCodes) div 2 * days
else
	set WKSToffset to days -- Default to Monday.
end if

Nigel,

Thanks for your help. I managed to resolve my need by downloading iCalBuddy, which I run in an AppleScript to create a daily email summary of my calendars.

Regards,

Des