Javascript + Calendar.app = crashing?


#1

I’ve been banging my head against the wall, trying to get a very simple script working.

I’m trying to read off a list of today’s events from Calendar.app. Apple actually provides an example script for doing just that. (See the ‘Locating an Event by Date’ section.) Here’s a very similar script from Stack Overflow:

var startOfDay = new Date();
startOfDay.setHours(0);
startOfDay.setMinutes(0);
startOfDay.setSeconds(0);
startOfDay.setMilliseconds(0);
var endOfDay = new Date();
endOfDay.setHours(23);
endOfDay.setMinutes(59);
endOfDay.setSeconds(59);
endOfDay.setMilliseconds(999);

var events = Application('Calendar').calendars.events.whose({
    _and: [
        { startDate: { _greaterThan: startOfDay }},
        { startDate: { _lessThan: endOfDay }}
    ]
});
var convertedEvents = events();
for (var cal of convertedEvents) {
    for (var ev of cal) { 
        console.log(ev.summary());
    }
}

But when I run this script in Script Editor, it runs forever and crashes Calendar.app and Script Editor. The script seems to hang on the .whose filter, and maybe that’s just because I have a lot of Calendar.app events? But I can’t see why that should be a problem.

Any insights would be much appreciated. I’m used to writing frontend/node.js, so my lack of experience with automation scripts might be hurting.

(P.S. I’m putting together a ‘daily planning’ script that compiles all of tomorrow’s events and tasks into a preformatted outline. Once I get around this issue, I’ll share the script here.)


#2

Well I found a way around this problem. Automator’s Calendar.app actions can basically do what I wanted to do (although it’s less flexible), and that doesn’t rely on the iffy JXA/Objective-C bridge.

(Automator uses EventKit to grab calendar data, which is faster/better than the Calendar.app application instance for JXA. I think you actually can access the EventKit framework through the Objective-C bridge with JXA, but that takes me into very unknown territory. Since Automator uses EventKit, you can also use Automator to retrieve/manipulate Reminders.app data, so that’s what I’d recommend for anyone trying to integrate with Reminders.app in the near future.)

And here’s the script I promised: ‘Magic’ daily planning script


#3

the iffy JXA/Objective-C bridge

Funnily enough, I’m not sure that it is genuinely iffy – I have done a fair amount of scripting with it, and somehow failed to encounter any problems with it – though I know there has been one insistently and sometimes comically coprolalic voice on Stack Overflow which has made a point of getting in first on every technical query to give a strong impression of iffiness as fast as possible :slight_smile:

(Some story of disappointment about non-adoption of their cocoa-based alternative, apparently)

I think the problem in the example you give may just be that whereas Apple’s example queries the events collection of a particular calendar object,

(and consulting the scripting library shows that Event objects are contained by specific calendar objects)

your first draft there is (understandably of course), not specifiying a calendar, and therefore going off looking across all events collections.

You would find, for example, that the code returned a faster result if you specified the first of your calendars by inserting [0], or a named calendar (like the Apple example), by inserting [someName]

Application('Calendar').calendars[0].events.whose( ...

The interface is not, however, fast …

(in the past I have resorted to SQL querying of the cache to get faster results)


#4

Thanks for the helpful comments!

I had a similar thought, but Apple’s example also results in the same crashing behavior when I select my main calendar. (It does work, although very slowly, for a ‘dummy’ calendar I made with just a handful of events.) That is, the problem, as far as I can tell, is that the .whose() filter just isn’t that fast, and when you throw a huge array at it, it simply takes too long.

(I also tried doing a nested foreach loop–first looping through calendars, then events–but that also crashes.)


#5

Yes, it’s certainly very slow, and may just be timing out with your data.

(My data is probably much smaller, but the Apple code still takes 3/4 seconds, though it does return.

Looks like a poorly optimized interface on that particular app. The JXA Automation object, and the ObjC bridge, have both been pretty fast and solid in my experience. I’ll take a look, over the weekend, at doing queries with this:

(function () {
    'use strict';

    ObjC.import('EventKit');

    var es = $.EKEventStore.alloc
        .initWithAccessToEntityTypes(
            (~~$.EKEntityMaskReminder | ~~$.EKEntityMaskEvent)
        );
   
})();

#6

Here’s an example of the kind of JavaScript for Automation code which can quickly fetch from the calendar using EventKit.

(You could use this route if you wanted to bypass Automator, for example)

// ROUGH EXAMPLE OF DIRECT JAVASCRIPT FOR AUTOMATION ACCESS 
// TO CALENDAR
// THROUGH EVENTKIT

// Draft 0.001 Rob Trew - Twitter: @ComplexPoint

// Simply listing the titles of todays events (all calendars)
// Other available properties:
        // (from EKCalendarItem):
        //     - calendar
        //     - title
        //     - location
        //     - creationDate
        //     - lastModifiedDate
        //     - timeZone
        //     - URL
        // (from EKEvent):
        // - eventIdentifier
        // - availability
        // - startDate
        // - endDate
        // - allDay
        // - occurrenceDate
        // - isDetached
        // - organizer
        // - status

(function () {
    'use strict';

    ObjC.import('EventKit');

    // calEventsFromTo Args:
    // 1. dateTime or undefined, defaults to start of today if undefined
    // 2. dateTime or undefined, defaults to end of today if undefined
    // 3. Possibly empty list of EKEvent and/or EKCalenderItem property names
    //       (if empty or undefined, defaults to [title, startDate])
    // 4. Possibly empty list of calendar name strings
    //      (if empty, undefined, or unrecognised, fetches events from all calendars)

    // calEventsFromTo :: maybe DateTime -> maybe DateTime -> [String] -> [String] -> Dictionary
    function calEventsFromTo(
        maybeStartDateTime, maybeEndDateTime,
        propertyNames, calendarNames
    ) {
        var es = $.EKEventStore.alloc
            .initWithAccessToEntityTypes(
                0
            ),

            // FROM a specified datetime, or start of today
            startDate = maybeStartDateTime ||
            $.NSCalendar.currentCalendar
            .startOfDayForDate(
                $.NSDate.date
            ),

            // Requested or default event fields
            // See Apple docs for EKEvent, EKCalendarItem
            lstProperties = propertyNames ? (
                typeof propertyNames === 'string' ? [propertyNames] :
                propertyNames
            ) : ['startDate', 'title'],

            sortedEvents = es.eventsMatchingPredicate(
                es.predicateForEventsWithStartDateEndDateCalendars(
                    // FROM
                    startDate,

                    // TO a specified datetime, or day after start
                    maybeEndDateTime ||
                    $.NSDate.dateWithTimeIntervalSinceDate(
                        (24 * 60 * 60) - 1,
                        startDate
                    ),

                    // CALENDARS TO SEARCH
                    es.calendarsForEntityType(0)
                    .filteredArrayUsingPredicate(
                        $.NSPredicate.predicateWithFormat(
                            // ANY CALENDAR (could be one or more names)
                            'title IN %@', (calendarNames ? (
                                typeof calendarNames === 'string' ? (
                                    [calendarNames]
                                ) : calendarNames
                            ) : [])
                        )
                    )
                )
            )
            .sortedArrayUsingSelector(
                'compareStartDateWithEvent:'
            ),

            // A dictionary in which each key -> an ordered list of values
            // (one for each event)
            dctValues = lstProperties
            .reduce(function (a, k) {
                return (
                    a[k] = ObjC.deepUnwrap(
                        sortedEvents.valueForKey(k)
                    ),
                    a
                );
            }, {
                calendar: ObjC.deepUnwrap(
                    sortedEvents
                    .valueForKey('calendar')
                    .valueForKey('title')
                )
            });


        // Given the list of the calendar strings (1 per event)
        // derive a list of full dictionaries for each event
        // with key:value pairs for each property requested
        return dctValues.calendar
            .map(function (strCal, i) {
                return lstProperties.reduce(function (a, k) {
                    return (
                        a[k] = dctValues[k][i],
                        a
                    );
                }, {
                    calendar: strCal
                })
            });
    }

    // Without arguments, returns 
    // calendar name, title, and startDate for 
    // all events (in all calendars) today
    return calEventsFromTo();
})();

'Magic' daily planning script
#7

You may, of course, also need to check that scripting access has been granted to the script-running app through

System Preferences > Security & Privacy > Accessibility 

with some kind of code along these lines:

(function () {
    'use strict';

    ObjC.import('EventKit');


    if (~~$.EKEventStore.authorizationStatusForEntityType(0) !== 3) {
        var sp = Application('System Preferences'),
            a = Application('System Events'),
            sa = (a.includeStandardAdditions = true, a),

            frontApps = sa.applicationProcesses.where({
                frontmost: true
            }),

            strName = frontApps.length ? frontApps[0].name() : '?';

        sa.activate();
        sa.displayDialog('Scripting access not yet given to ' + strName, {
            buttons: ['OK'],
            defaultButton: 'OK',
            withTitle: 'Scripting access to Calendar Events',
            givingUpAfter: 30
        });

        sp.activate();
        sp.panes["com.apple.preference.security"]
            .anchors['Privacy'].reveal();

    }
})();


#8

and for today’s Reminders, of course, the Automation object interface seems to be fairly simple and fast - more useful, in the case of Reminders, than the EventKit approach, (see second code snippet further down) which fetches asynchronously, allowing it to work with the Reminders data, but not allowing it to (straightforwardly at least) return data to a calling function.

(function () {
    'use strict';

    var dateStart = $.NSCalendar.currentCalendar
        .startOfDayForDate(
            $.NSDate.date
        )
        .js,

        dateEnd = $.NSDate.dateWithTimeIntervalSinceDate(
            (24 * 60 * 60) - 1,
            dateStart
        )
        .js,

        rms = Application('Reminders'),
        reminders = rms.reminders.where({
            _and: [
                {
                    dueDate: {
                        '>=': dateStart
                    }
                },
                {
                    dueDate: {
                        '<=': dateEnd
                    }
                }
            ]
        });

    return reminders.name();
})();

Contrast this with the trickier (asynchronous) fetching of Reminders by the EKEventStore route

// ROUGH EXAMPLE OF DIRECT JAVASCRIPT FOR AUTOMATION ACCESS 
// TO REMINDERS
// THROUGH EVENTKIT

// Simply listing the titles of todays reminders (all lists)
// Probably less useful than Application('Reminders')
// as the fetch, being asynchronous, can work with returned data
// but can't return it to a calling function

(function () {
    'use strict';

    ObjC.import('EventKit');

    var es = $.EKEventStore.alloc
        .initWithAccessToEntityTypes(
            1
        ),
        startDate = $.NSCalendar.currentCalendar
        .startOfDayForDate(
            $.NSDate.date
        ),
        daySeconds = 24 * 60 * 60;


    var a = Application.currentApplication(),
        sa = (a.includeStandardAdditions = true, a);


    es.fetchRemindersMatchingPredicateCompletion(
        es.predicateForIncompleteRemindersWithDueDateStartingEndingCalendars(
            startDate,
            $.NSDate.dateWithTimeIntervalSinceDate(
                daySeconds - 1,
                startDate
            ),
            es.calendarsForEntityType(1)
        ),
        // ENTERING A SEPARATE (ASYNCHRONOUS) EXECUTION CONTEXT HERE
        // (closure variables can be read but not written from here)
        function (reminders) {
            var lstResults = ObjC.deepUnwrap(
                reminders.valueForKey('title')
            );

            // EKReminder
            // - startDateComponents
            // - dueDateComponents
            // - completed
            // - completionDate

            sa.activate()
            sa.displayDialog(lstResults.join('\n'));

            // CAN'T RETURN ANYTHING TO THE CALLING FUNCTION HERE
            // THIS COMPLETION LAMBDA IS EXECUTED ASYNCHRONOUSLY
        }
    );

})();

#9

Wow. @complexpoint - You may the most helpful person ever. This is awesome. As someone new to scripting on the Mac, this sort of help makes it so much less painful to figure the new environment out. I will be integrating this into my script soon and post an update with the results here. Thanks so much!


#10

Have fun !

A quick PS, if a script-launching app/context like Keyboard Maestro, Atom.app etc turns out not to have yet been given permission to read Calendars etc from the the EventStore, you can get to the permission-granting dialog for that app

by including code like:

(function () {
    'use strict';

    ObjC.import('EventKit');

    var es = $.EKEventStore.alloc
        .initWithAccessToEntityTypes(
            0
        )
        
    if (~~$.EKEventStore.authorizationStatusForEntityType(0) !== 3) {
        es.requestAccessToEntityTypeCompletion(0, function (granted, error) {
            if (granted) {
                // ...
            }
        });
    }
})();

( Note that the Completion lambda is executed asynchronously, so it can fully read from the closure context, but can’t write to it, or return a value to it)

Reminders:

for Reminders the entity type constant is 1, so we can similarly get to:

with:

if (~~$.EKEventStore.authorizationStatusForEntityType(1) !== 3) {
        es.requestAccessToEntityTypeCompletion(1, function (granted, error) {
            if (granted) {
                // ...
            }
        });
    }