Building Local Calendar Sync Day 03: Building the Calendar Sync Logic

Day three of building Calendar Sync, which runs locally on my computer and will hopefully solve the Calendar Problem in my life. Today, we got the sync logic working for the first time.

Building Local Calendar Sync Day 03: Building the Calendar Sync Logic

After the first experiments, it was time to move all logic that belongs to syncing and interacting with the calendars to its own class. To I created a new CalendarService.swift class in the project and added the logic.

You can see, that I hardcoded the IDs of the calendars that should be synced are hardcoded for now. Later, these calendars shall be selectable in a Settings UI, but for now this is a good first start. I got these calendar IDs by running the following code, which lists all calendars with their IDs once:

func printAllCalendars() -> Void {
    let calendars = store.calendars(for: .event)
    let groupedBySource = Dictionary(grouping: calendars, by: { $0.source.title })
    for (source, calendars) in groupedBySource {
        print("\\(source):")
        for calendar in calendars {
		        print("- \\(calendar) (\\(calendar.calendarIdentifier))")
        }
    }
}

The output this function produced let me choose, which calendars are relevant for me. With this knowledge, I was able to craft the following class:

import Foundation
import EventKit

class CalendarService {
    // Singleton Instance
    static let shared = CalendarService()
    
    private let store = EKEventStore()
    private let blockerTitle = "Blocker"
    private let blockerNotes = "created by Local Calendar Sync"
    
    // Hardcoded for now. Will be set via Settings later
    private let calendarIds = [
        "433EC696-0453-40FC-B101-B41D746881D6", // Startup
        "F6224FCB-AACD-4FD3-90B6-730EE182887C", // Personal
        "276277DF-E3CF-4B89-BBB5-73F0A0E6C538" // Work
    ]

    func initAccess() async throws -> Void {
        guard try await store.requestFullAccessToEvents() else { return }
    }
    
    func sync() async -> Void {
        // Delete all existing blockers first
				// Deleting all blockers, regardless if they are still valid or not just to re-create them seconds later
				// Is not no the nicest and most performant way for sure, but it allows us to run the sync script
				// More than once at the moment. In the future, we will find a smarter logic for this.
        await deleteAllBlockers()
        
        let fromCalendars = getFromCalendars()
        let toCalendars = getToCalendars()
        
        // Iterate over all from-calendars
        for fromCal in fromCalendars {
            // Get relevant evnets from calendar
            let relevantEvents = getRelevantEvents(calendar: fromCal)
            // Create a blocke for each event in each to-calendar
            for event in relevantEvents {
                for toCal in toCalendars {
                    // Don't create a Blocker in the same calendar, that the current event is originating from.
                    if (toCal == fromCal) {
                        continue
                    }
                    
                    await createBlocker(calendar: toCal, event: event)
                }
            }
        }
    }
    
    func deleteAllBlockers() async -> Void {
        let calendars = getToCalendars()
        
        // Fetch blocker events from calendars within 360 days from now        
        let events = store.events(matching: store.predicateForEvents(
            withStart: Date(),
						end: Date().addingTimeInterval(365 * 24 * 60 * 60), // 1 year
            calendars: calendars))
        
        // Filter events down to blockers
        let blockers = events.filter { $0.title.contains(blockerTitle) && $0.notes?.contains(blockerNotes) ?? false }
        for blocker in blockers {
            do {
                try store.remove(blocker, span: .thisEvent)
            } catch {
              print("deleting event error: \\(error)")
            }
        }
    }
    
    private func getFromCalendars() -> [EKCalendar] {
        let calendars = store.calendars(for: .event)
        let filtered = calendars.filter { calendar in calendarIds.contains(calendar.calendarIdentifier) }
        return filtered
    }
    
    private func getToCalendars() -> [EKCalendar] {
        let calendars = store.calendars(for: .event)
        let filtered = calendars.filter { calendar in calendarIds.contains(calendar.calendarIdentifier) }
        return filtered
    }            
    
    private func getRelevantEvents(calendar: EKCalendar) -> [EKEvent] {
        // Fetch blocker events from calendars within 360 days from now
        let events = store.events(matching: store.predicateForEvents(
            withStart: Date(),
            end: Date().addingTimeInterval(365 * 24 * 60 * 60), // 1 year
            calendars: [calendar])
        
        // Filter events down to busy events only
        let relevantEvents = events.filter { $0.availability == EKEventAvailability.busy }                
        return relevantEvents
    }
    
    private func createBlocker(calendar: EKCalendar, event: EKEvent) async -> Void {
        // Create a test event
        let blocker = EKEvent.init(eventStore: store)
        blocker.title = blockerTitle
        blocker.availability = EKEventAvailability.busy
        blocker.notes = blockerNotes
        blocker.startDate = event.startDate
        blocker.endDate = event.endDate
        blocker.isAllDay = event.isAllDay
        blocker.calendar = calendar
                
        do {
            try store.save(blocker, span: .thisEvent)
            print("Created Blocker for \\(event.title!) in \\(calendar.title) (\\(calendar.source.title))")
        } catch {
          print("saving event error: \\(error)")
        }
    }        
}

Before using it in my UI, I wanted to create a singleton instance for this service in my Application. According to the Apple Developer Documentation, this is achieved by adding static instance called shared of its own to the class.

// Singleton Instance 
static let shared = CalendarService()

Now I was ready to call the sync() and deleteAllBlockers() (for cleanup in case I messed something up) methods from the menu. So in the CalSyncApp.swift file, where the Menu is defined, I added the function calls.

Button("Delete Blockers") {
    Task {
        do {
            try await CalendarService.shared.initAccess()
        } catch {
            print("Error")
        }
        
        await CalendarService.shared.deleteAllBlockers()                    
    }
}

Button("Sync now") {
    Task {
        do {
            try await CalendarService.shared.initAccess()
        } catch {
            print("Error")
        }
        
        await CalendarService.shared.sync()
    }   
}

To be honest - I was a bit scared, when I clicked the “Sync now” button for the first time, crossing my fingers and hoping, that I did not completely mess up my calendar or deleted any important meeting by accident.

In the screenshot. you can see that the tool only started to create blockers for future events from the moment on it ran and and ignored free events like my Lunch Break or those events that were unconfirmed invitations.

But to my relief, it all went smooth and for the first time, I was able to take a look at my synced calendar the Blockers I wanted for so long. 🎉


☝️ Advertisement Block: I will buy myself a pizza every time I make enough money with these ads to do so. So please feed a hungry developer and consider disabling your Ad Blocker.