Recurring reminders

Hi there,
Apple’s application Reminders seems not to support scripting for recurring reminders (daily, weekly, ect reminders) or i’m wrong?

There doesn’t seem to be any support for recurrences in Reminders’s scripting dictionary. (It’s not obvious in the GUI either unless you read the Reminders Help — which you can’t if you’ve just switched off your router! But that’s another issue. ;))

It’s possible to script recurrences using the EventKit framework in ASObjC, but it’s a messy business. You can’t just write an RFC2445 recurrence rule and apply it. You have to create an object of class EKRecurrenceRule whose properties and values may themselves be classes or predefined constants. If you’re scripting the creation of the reminder itself, the start and due dates (I don’t personally know the difference between these) have to be supplied as date components rather than as dates per se. If you want to set or change the recurrence of an existing reminder, the only way I can see to get the reminders is to use an undocumented method I tried on the offchance this morning. (It works on High Sierra in all three of my script editors.)

I don’t have any more time to look into it at the moment. But I’m excited to see that although Calendar continues to observe only one recurrence rule per event (and presumably Reminders only one per reminder), the underlying framework appears to support multiple RRULEs as allowed by RFC2445. iCal used to observe these, although it couldn’t set them itself.

Sounds interesting and doable
It’s a good thing if ObjC allows that flexibility - on the other hand if the coding of such a little thing adds such layers of difficulty, then we do better better to use again our good old Calendar

Recent 3rd party applications seem all to ignore applescript - support and prefer to use a browser framework instead
At this point I suggest we code our own script dictionaries - which shouldn’t be impossible, right? At least for open source applications. Which are coded mostly for Linux-I hope Swift established itself as tool for both developers in the closer future. Applications without applescript support make me unhappy. Really.

Well here’s a rough, proof-of-concept attempt. It must be possible to do it with less code, but at least it works — although how perfectly remains to be seen. :wink:

Edit: A few minor alterations to the script in the light of better understanding.

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use framework "EventKit"
use scripting additions

property |⌘| : a reference to current application

main()

on main()
	-- Crudely set parameters for a reminder in the "Home" calendar which, hopefully, repeats every Monday, Wednesday, Friday, and Saturday at 11:00 until 22nd December 2017.
	set calendarName to "Home" -- Calendars are apparently called "lists" in Reminders.
	set reminderTitle to "This is a repeating reminder created by script."
	-- The "due date" is apparently when the task/event of which one's to be reminded is due to be done/take place.
	set ymdhms to {2017, 12, 16, 11, 0, 30} -- For 16th December 2017 11:00:30. Reminders itself doesn't set or show seconds, but the alarm triggers at the appropriate time.
	tell (current date) to set {dueDate, its day, {its year, its month, its day, its hours, its minutes, its seconds}} to {it, 1, ymdhms}
	-- When one's actually reminded is governed by the alarm setting, here two hours before the due date.
	set displayAlarmOffset to -2 * hours
	-- startDate is a reminder property in EventKit, but has no obvious equivalent or influence in Reminders.
	copy dueDate to startDate
	-- The RRULE governing "due date" recurrences. The next recurrence isn't seen in Reminders until the current one's "completed". Both are then visible if the current one's subsequently "decompleted".
	set RRULE to "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR,SA;UNTIL=20171222T110000Z"
	
	set RemindersWasOpen to (application "Reminders"'s running)
	if (RemindersWasOpen) then quit application "Reminders"
	
	-- Get the target calendar from the calendar store.
	set calendarStore to |⌘|'s class "EKEventStore"'s alloc()'s initWithAccessToEntityTypes:(|⌘|'s EKEntityMaskReminder)
	set targetCalendar to getCalendar(calendarStore, calendarName)
	-- Derive an NSDateComponents object from the given AppleScript date, to use for both the start date and due date. (I don't know the difference.)
	set startDateComponents to NSDateComponentsFromASDate(startDate)
	set dueDateComponents to NSDateComponentsFromASDate(dueDate)
	-- Make a new reminder in the calendar (but not yet committed).
	set newReminder to makeReminder(calendarStore, targetCalendar, reminderTitle, startDateComponents, dueDateComponents)
	-- Extract the information from the RRULE in a form useful for making an EKRecurrenceRule.
	set recurrenceInfoRecord to parseRRULE(RRULE)
	-- Make the rule and add it to the reminder.
	set recurrenceRule to makeEKRecurrenceRule(recurrenceInfoRecord)
	tell newReminder to addRecurrenceRule:(recurrenceRule)
	-- Set a bog-standard "Remind me" notification alarm.
	set theAlarm to makeDisplayAlarmWithOffset(displayAlarmOffset)
	tell newReminder to addAlarm:(theAlarm)
	-- Commit the new reminder to the database.
	tell calendarStore to saveReminder:(newReminder) commit:(true) |error|:(reference)
	
	if (RemindersWasOpen) then activate application "Reminders"
end main

-- Get the specified calendar.
on getCalendar(calendarStore, calendarName)
	set reminderCalendars to calendarStore's calendarsForEntityType:(|⌘|'s EKEntityTypeReminder)
	set predicateForTargetCalendar to |⌘|'s class "NSPredicate"'s predicateWithFormat_("title = %@", calendarName)
	set targetCalendar to (reminderCalendars's filteredArrayUsingPredicate:(predicateForTargetCalendar))'s firstObject()
	
	return targetCalendar
end getCalendar

-- Make a new reminder whose home will be the specified calendar, if it's actually saved.
on makeReminder(calendarStore, targetCalendar, reminderTitle, startDateComponents, dueDateComponents)
	tell (|⌘|'s class "EKReminder"'s reminderWithEventStore:(calendarStore))
		its setCalendar:(targetCalendar)
		its setTitle:(reminderTitle)
		its setStartDateComponents:(startDateComponents)
		its setDueDateComponents:(dueDateComponents)
		
		return it
	end tell
end makeReminder

-- Derive an NSDateComponents object from an NSDate.
on NSDateComponentsFromASDate(ASDate)
	set {year:y, month:m, day:d, hours:hr, minutes:min, seconds:sec} to ASDate
	tell |⌘|'s class "NSDateComponents"'s alloc()'s init()
		its setCalendar:(|⌘|'s class "NSCalendar"'s calendarWithIdentifier:(|⌘|'s NSCalendarIdentifierGregorian))
		its setYear:(y)
		its setMonth:(m as integer)
		its setDay:(d)
		its setTimeZone:(|⌘|'s class "NSTimeZone"'s systemTimeZone())
		its setHour:(hr)
		its setMinute:(min)
		its setSecond:(sec)
		
		return it
	end tell
end NSDateComponentsFromASDate

on parseRRULE(RRULE)
	set RRULE to |⌘|'s class "NSString"'s stringWithString:(RRULE)
	-- Set up look-up tables to save repeating with some rule parts.
	set frequencyCodeLookup to |⌘|'s class "NSDictionary"'s dictionaryWithDictionary:({DAILY:|⌘|'s EKRecurrenceFrequencyDaily, WEEKLY:|⌘|'s EKRecurrenceFrequencyWeekly, MONTHLY:|⌘|'s EKRecurrenceFrequencyMonthly, YEARLY:|⌘|'s EKRecurrenceFrequencyYearly})
	set weekdayNumberLookup to |⌘|'s class "NSDictionary"'s dictionaryWithDictionary:({SU:1, MO:2, TU:3, WE:4, |TH|:5, FR:6, SA:7})
	-- Initialise a record for the output. The final values will be a mixture of AS and EventKit objects suitable for use with initRecurrenceWithFrequency:interval:daysOfTheWeek:daysOfTheMonth:monthsOfTheYear:weeksOfTheYear:daysOfTheYear:setPositions:end:.
	set outputRecord to {frequency:missing value, interval:missing value, daysOfTheWeek:missing value, daysOfTheMonth:missing value, monthsOfTheYear:missing value, weeksOfTheYear:missing value, daysOfTheYear:missing value, setPositions:missing value, |end|:missing value, firstDayOfTheWeek:missing value}
	
	-- Match the individual rule parts in the RRULE.
	set rulePartRegex to |⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:("([[:alpha:]]++)=([^;]++)") options:(0) |error|:(missing value)
	set rulePartMatches to rulePartRegex's matchesInString:(RRULE) options:(0) range:({0, RRULE's |length|()})
	-- Deal with each rule part as encountered.
	repeat with thisMatch in rulePartMatches
		-- Extract this rule part's type (as text, for convenience) and value (as NSString, upper-cased, for convention).
		set partType to (RRULE's substringWithRange:(thisMatch's rangeAtIndex:(1))) as text
		set partValue to (RRULE's substringWithRange:(thisMatch's rangeAtIndex:(2)))'s uppercaseString()
		
		if (partType is "FREQ") then
			-- Read the relevant frequency code from our look-up dictionary.
			set outputRecord's frequency to (frequencyCodeLookup's objectForKey:(partValue))
		else if (partType is "INTERVAL") then
			-- Get the value in the RRULE as integer.
			set outputRecord's interval to partValue as integer -- Direct coercion of NSString to integer is possible in both 10.11 and 10.13.
		else if (partType is "BYDAY") then
			-- Match the individual weekday abbreviations and any associated week numbers.
			set numberWeekdayRegex to (|⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:("(-?\\d++)?(SU|MO|TU|WE|TH|FR|SA)(?=,|$)") options:(0) |error|:(missing value))
			set BYDAYValueMatches to (numberWeekdayRegex's matchesInString:(partValue) options:(0) range:({0, partValue's |length|}))
			-- Parse out each weekday abbreviation separately from any week number, create a suitable dayOfWeek object from it/them, and store in a list.
			set outputRecord's daysOfTheWeek to {}
			repeat with thisMatch in BYDAYValueMatches
				-- Get the weekday abbreviation.
				set weekdayAbbreviationRange to (thisMatch's rangeAtIndex:(2))
				set weekdayAbbreviation to (partValue's substringWithRange:(weekdayAbbreviationRange))
				-- Read its weekday number from our look-up dictionary.
				set weekdayNumber to (weekdayNumberLookup's objectForKey:(weekdayAbbreviation))
				-- Create the dayOfWeek object according to whether or not there's a week number too.
				set weekNumberRange to (thisMatch's rangeAtIndex:(1))
				if (weekNumberRange's |length|() is 0) then
					-- Weekday only.
					set weekdayObject to (|⌘|'s class "EKRecurrenceDayOfWeek"'s dayOfWeek:(weekdayNumber))
				else
					-- Weekday and week number.
					set weekNumber to (partValue's substringWithRange:(weekNumberRange)) as integer
					set weekdayObject to (|⌘|'s class "EKRecurrenceDayOfWeek"'s dayOfWeek:(weekdayNumber) weekNumber:(weekNumber))
				end if
				set end of outputRecord's daysOfTheWeek to weekdayObject
			end repeat
		else if (partType is "WKST") then
			-- Read the weekday number from our look-up dictionary.
			set outputRecord's firstDayOfTheWeek to (weekdayNumberLookup's objectForKey:(partValue))
		else if (partType is "COUNT") then
			-- Get the value in the RRULE as integer.
			set outputRecord's |end| to (|⌘|'s class "EKRecurrenceEnd"'s recurrenceEndWithOccurrenceCount:(partValue as integer))
		else if (partType is "UNTIL") then
			-- Create an NSDate from the RRULE value and an ENRecurrenceEnd object from that.
			set dateFormatter to |⌘|'s class "NSDateFormatter"'s new()
			set formatString to partValue's mutableCopy()
			tell formatString to replaceOccurrencesOfString:("^\\d{8}+(?=T|$)") withString:("yyyyMMdd") options:(|⌘|'s NSRegularExpressionSearch) range:({0, its |length|()})
			tell formatString to replaceOccurrencesOfString:("T\\d{6}+(?!\\d)") withString:("'T'HHmmss") options:(|⌘|'s NSRegularExpressionSearch) range:({0, its |length|()})
			tell formatString to replaceOccurrencesOfString:("(?<=^yyyyMMdd'T'HHmmss)( *+).++$") withString:("$1XX") options:(|⌘|'s NSRegularExpressionSearch) range:({0, its |length|()})
			tell dateFormatter to setDateFormat:(formatString)
			set outputRecord's |end| to (|⌘|'s class "EKRecurrenceEnd"'s recurrenceEndWithEndDate:(dateFormatter's dateFromString:(partValue)))
		else if (partType is "BYMONTHDAY") then -- All other rule-part types involve integer arrays.
			set outputRecord's daysOfTheMonth to integerArrayFromParValue(partValue)
		else if (partType is "BYMONTH") then
			set outputRecord's monthsOfTheYear to integerArrayFromParValue(partValue)
		else if (partType is "BYWEEKNO") then
			set outputRecord's weeksOfTheYear to integerArrayFromParValue(partValue)
		else if (partType is "BYYEARDAY") then
			set outputRecord's daysOfTheYear to integerArrayFromParValue(partValue)
		else if (partType is "BYSETPOS") then
			set outputRecord's setPositions to integerArrayFromParValue(partValue)
		end if
	end repeat
	
	return outputRecord
end parseRRULE

-- Derive an array of integers from a comma-delimited-integer text. The EventKit method to which they'll be fed does accept NSStrings, but I don't know how these effect the recurrence behaviour.
on integerArrayFromParValue(partValue)
	set integerArray to (partValue's componentsSeparatedByString:(",")) -- This produces a mutable array!!!
	set integerArray to integerArray's mutableCopy() -- … but not taking chances.
	repeat with i from 1 to (count integerArray)
		set item i of integerArray to (item i of integerArray) as integer
	end repeat
	
	return integerArray
end integerArrayFromParValue

-- Make an EKRecurrenceRule using the supplied data.
on makeEKRecurrenceRule({frequency:frequency, interval:interval, daysOfTheWeek:daysOfTheWeek, daysOfTheMonth:daysOfTheMonth, monthsOfTheYear:monthsOfTheYear, weeksOfTheYear:weeksOfTheYear, daysOfTheYear:daysOfTheYear, setPositions:setPositions, |end|:|end|, firstDayOfTheWeek:firstDayOfTheWeek})
	set theRule to |⌘|'s class "EKRecurrenceRule"'s alloc()'s initRecurrenceWithFrequency:(frequency) interval:(interval) daysOfTheWeek:(daysOfTheWeek) daysOfTheMonth:(daysOfTheMonth) monthsOfTheYear:(monthsOfTheYear) weeksOfTheYear:(weeksOfTheYear) daysOfTheYear:(daysOfTheYear) setPositions:(setPositions) |end|:(|end|)
	if (firstDayOfTheWeek is not missing value) then tell theRule to setFirstDayOfTheWeek:(firstDayOfTheWeek)
	return theRule
end makeEKRecurrenceRule

-- Make an alarm set to trigger at a certain amount of time from its cause. By default, it's a display alarm.
on makeDisplayAlarmWithOffset(offsetInSeconds)
	return |⌘|'s class "EKAlarm"'s alarmWithRelativeOffset:(offsetInSeconds)
end makeDisplayAlarmWithOffset