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.
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.
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.