// Copyright © 2015 Abhishek Banthia import Cocoa import CoreLoggerKit import EventKit extension EventCenter { func calendarAccessGranted() -> Bool { return EKEventStore.authorizationStatus(for: .event) == .authorized } func calendarAccessNotDetermined() -> Bool { return EKEventStore.authorizationStatus(for: .event) == .notDetermined } func calendarAccessDenied() -> Bool { return EKEventStore.authorizationStatus(for: .event) == .denied } func fetchSourcesAndCalendars() -> [Any] { var sourcesAndCalendars: [Any] = [] // Fetch array of user's calendars sorted first by source title and then by calendar title let calendars = eventStore.calendars(for: .event).sorted { cal1, cal2 -> Bool in if cal1.source.sourceIdentifier == cal2.source.sourceIdentifier { return cal1.title < cal2.title } return cal1.source.title < cal2.source.title } // Now time to fetch the events // Fetch the user-selected calendars. Initially, all the calendars will be selected var setOfCalendars: Set = Set() if let userCalendars = DataStore.shared().selectedCalendars(), !userCalendars.isEmpty { setOfCalendars = Set(userCalendars) } var currentSourceTitle = CLEmptyString for calendar in calendars { if !(calendar.source.title == currentSourceTitle) { sourcesAndCalendars.append(calendar.source.title) currentSourceTitle = calendar.source.title } let isCalendarSelected = setOfCalendars.contains(calendar.calendarIdentifier) let calendarInfo = CalendarInfo(calendar: calendar, selected: isCalendarSelected) sourcesAndCalendars.append(calendarInfo) } return sourcesAndCalendars } func isThereAnUpcomingCalendarEvent() -> Bool { if DataStore.shared().shouldDisplay(.showMeetingInMenubar) { let filteredDates = EventCenter.sharedCenter().eventsForDate let autoupdatingCal = EventCenter.sharedCenter().autoupdatingCalendar guard let events = filteredDates[autoupdatingCal.startOfDay(for: Date())] else { return false } for event in events { if event.event.startDate.timeIntervalSinceNow > 0, !event.isAllDay { let timeForEventToStart = event.event.startDate.timeIntervalSinceNow / 60 if timeForEventToStart > 30 { Logger.info("Our next event: \(event.event.title ?? "Error") starts in \(timeForEventToStart) mins") continue } return true } } } return false } /* Used for the compact menubar mode. Returns a tuple with 0 as the header string and 1 as the subtitle string */ func separateFormat(event: EKEvent) -> (String, String)? { guard let truncateLength = DataStore.shared().retrieve(key: CLTruncateTextLength) as? NSNumber, let eventTitle = event.title else { return nil } let seconds = event.startDate.timeIntervalSinceNow var formattedTitle: String = CLEmptyString if eventTitle.count > truncateLength.intValue { let truncateIndex = eventTitle.index(eventTitle.startIndex, offsetBy: truncateLength.intValue) let truncatedTitle = String(eventTitle[.. 2 { let suffix = String(format: " in %0.f mins", minutes) menubarText.append(suffix) } else if minutes == 1 { let suffix = String(format: " in %0.f min", minutes) menubarText.append(suffix) } else { menubarText.append(" starts now.") } return (formattedTitle, menubarText) } func nextOccuring(_: [EventInfo]) -> EventInfo? { if calendarAccessDenied() || calendarAccessNotDetermined() { return nil } let relevantEvents = filteredEvents[autoupdatingCalendar.startOfDay(for: Date())] ?? [] let filteredEvents = relevantEvents.filter { $0.event.isAllDay == false && $0.event.endDate.timeIntervalSinceNow > 0 && $0.event.startDate.timeIntervalSinceNow > -300 && $0.event.status != .canceled } if filteredEvents.count == 1 { return filteredEvents.first } // If there are multiple events coming up, prefer the ones the currentUser has accepted let acceptedEvents = filteredEvents.filter { $0.attendeStatus == .accepted } let optionalEvents = filteredEvents.filter { $0.attendeStatus == .tentative } if let firstAcceptedEvent = acceptedEvents.first { return firstAcceptedEvent } // If there are no accepted events, prefer the first optional event if acceptedEvents.isEmpty, !optionalEvents.isEmpty { return optionalEvents.first } // Otherwise check if there's a filtered event at all and return it if let first = filteredEvents.first { return first } let filteredAllDayEvent = relevantEvents.filter { $0.isAllDay }.first return filteredAllDayEvent } func upcomingEventsForDay(_: [EventInfo]) -> [EventInfo]? { if calendarAccessDenied() || calendarAccessNotDetermined() { return nil } let todayEvents = filteredEvents[autoupdatingCalendar.startOfDay(for: Date())] ?? [] let tomorrowEvents = filteredEvents[autoupdatingCalendar.startOfDay(for: Date().addingTimeInterval(86400))] ?? [] let relevantEvents = todayEvents + tomorrowEvents return relevantEvents.filter { $0.event.startDate.timeIntervalSinceNow > -300 } } func initializeStoreIfNeccesary() { if eventStore == nil { eventStore = EKEventStore() } } func requestAccess(to entity: EKEntityType, completionHandler: @escaping (_ granted: Bool) -> Void) { initializeStoreIfNeccesary() eventStore.requestAccess(to: entity) { [weak self] granted, error in // On successful granting of calendar permission, we default to showing events from all calendars if let self = self, entity == .event, granted { self.saveDefaultIdentifiersList() } else if let requestError = error { Logger.info("Unable to request events access due to \(requestError.localizedDescription)") } else { Logger.info("Request events access failed silently") } completionHandler(granted) } } func filterEvents() { filteredEvents = [:] if let selectedCalendars = DataStore.shared().selectedCalendars() { for date in eventsForDate.keys { if let events = eventsForDate[date] { for event in events { if event.event.calendar != nil && selectedCalendars.contains(event.event.calendar.calendarIdentifier) { if filteredEvents[date] == nil { filteredEvents[date] = Array() } filteredEvents[date]?.append(event) } } } } Logger.info("Fetched filtered events for \(filteredEvents.count) days\n") return } Logger.info("Unable to filter events because user hasn't selected calendars") } func saveDefaultIdentifiersList() { OperationQueue.main.addOperation { [weak self] in guard let self = self else { return } let allCalendars = self.retrieveAllCalendarIdentifiers() if !allCalendars.isEmpty { UserDefaults.standard.set(allCalendars, forKey: CLSelectedCalendars) Logger.info("Finished saving all calendar identifiers in default") self.filterEvents() } } } func retrieveAllCalendarIdentifiers() -> [String] { return eventStore.calendars(for: .event).map { calendar -> String in calendar.calendarIdentifier } } private func createDateComponents(with calendar: NSCalendar?, _ day: Int) -> Date { var dateComps = DateComponents() dateComps.day = day guard let convertedDate = calendar?.date(byAdding: dateComps, to: Date(), options: NSCalendar.Options.matchFirst) else { return Date() } return convertedDate } private func shouldSkipEvent(_ event: EKEvent) -> Bool { if event.hasAttendees, let attendes = event.attendees { for participant in attendes where participant.isCurrentUser && participant.participantStatus == .declined { return true } } return false } func fetchEvents(_ start: Int, _ end: Int) { if calendarAccessDenied() || calendarAccessNotDetermined() { Logger.info("Refetching aborted because we don't have permission!") return } initializeStoreIfNeccesary() let calendar = NSCalendar(calendarIdentifier: NSCalendar.Identifier.gregorian) let startDate = createDateComponents(with: calendar, start) let endDate = createDateComponents(with: calendar, end) // Passing in nil for calendars to search all calendars let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: nil) var eventsForDateMapper: [Date: [EventInfo]] = [:] let events = eventStore.events(matching: predicate) // Populate our cache with events that match our startDate and endDate. // We map eachDate to array of events happening on that day for event in events where shouldSkipEvent(event) == false { // Iterate through the days this event spans. We only care about // days for this event that are between startDate and endDate let eventStartDate = event.startDate as NSDate let eventEndDate = event.endDate as NSDate var date = eventStartDate.laterDate(startDate) let final = eventEndDate.earlierDate(endDate) date = autoupdatingCalendar.startOfDay(for: date) while date.compare(final) == .orderedAscending { guard var nextDate = autoupdatingCalendar.date(byAdding: Calendar.Component.day, value: 1, to: date) else { Logger.info("Could not calculate end date") return } nextDate = autoupdatingCalendar.startOfDay(for: nextDate) if eventsForDateMapper[date] == nil { eventsForDateMapper[date] = [] } eventsForDateMapper[date]?.append(generateEventInfo(for: event, date, nextDate)) date = nextDate } } // We now sort the array so that AllDay Events are first, then sort by startTime for date in eventsForDateMapper.keys { let sortedEvents = eventsForDateMapper[date]?.sorted(by: { event1, event2 -> Bool in if event1.isAllDay { return true } else if event2.isAllDay { return false } else { return event1.event.startDate < event2.event.startDate } }) eventsForDateMapper[date] = sortedEvents } eventsForDate = eventsForDateMapper Logger.info("Fetched events for \(eventsForDate.count) days") filterEvents() } private func generateEventInfo(for event: EKEvent, _ date: Date, _ nextDate: Date) -> EventInfo { // Make a customized struct let isAllDay = event.isAllDay || (event.startDate.compare(date) == .orderedAscending && event.endDate.compare(nextDate) == .orderedSame) let eventParticipationStatus = attendingStatusForUser(event) let meetingURL = retrieveMeetingURL(event) let eventInfo = EventInfo(event: event, isAllDay: isAllDay, meetingURL: meetingURL, attendeStatus: eventParticipationStatus) return eventInfo } static var dataDetector: NSDataDetector? // Borrowing logic from Ityscal @discardableResult private func findAppropriateURLs(_ description: String) -> URL? { guard let results = EventCenter.dataDetector?.matches(in: description, options: .reportCompletion, range: NSRange(location: 0, length: description.count)), results.isEmpty == false else { return nil } for result in results { if result.resultType == .link, var actualLink = result.url?.absoluteString { // Check for Zoom links if actualLink.contains("zoom.us/j/") || actualLink.contains("zoom.us/s/") || actualLink.contains("zoom.us/w/") || actualLink.contains("zoom.us/my/") || actualLink.contains("zoomgov.com/j/") || actualLink.contains("zoomgov.com/s/") || actualLink.contains("zoomgov.com/w/") || actualLink.contains("zoomgov.com/my/") { // Create a Zoom App link let workspace = NSWorkspace.shared if workspace.urlForApplication(toOpen: URL(string: "zoommtg://")!) != nil { actualLink = actualLink.replacingOccurrences(of: "https://", with: "zoommtg://") actualLink = actualLink.replacingOccurrences(of: "?", with: "&") actualLink = actualLink.replacingOccurrences(of: "/j/", with: "/join?confno=") actualLink = actualLink.replacingOccurrences(of: "/s/", with: "/join?confno=") actualLink = actualLink.replacingOccurrences(of: "/w/", with: "/join?confno=") if let appLink = URL(string: actualLink) { return appLink } } } else if (actualLink.contains("teams.microsoft.com/l/meetup-join")) { let workSpace = NSWorkspace.shared if workSpace.urlForApplication(toOpen: URL(string:"msteams://")!) != nil { let sanitizedString = actualLink.replacingOccurrences(of: "https://", with: "msteams://") if let sanitizedURL = URL(string: sanitizedString) { return sanitizedURL } } } else if (actualLink.contains("chime.aws/")) { let workSpace = NSWorkspace.shared if workSpace.urlForApplication(toOpen: URL(string:"chime://")!) != nil { let sanitizedString = actualLink.replacingOccurrences(of: "https://chime.aws/", with: "chime://meeting?pin=") if let sanitizedURL = URL(string: sanitizedString) { return sanitizedURL } } } else if actualLink.contains("zoommtg://") || actualLink.contains("msteams://") || actualLink.contains("chime://") || actualLink.contains("meet.google.com/") || actualLink.contains("hangouts.google.com/") || actualLink.contains("webex.com/") || actualLink.contains("gotomeeting.com/join") || actualLink.contains("ringcentral.com/j") || actualLink.contains("bigbluebutton.org/gl") || actualLink.contains("https://bigbluebutton.") || actualLink.contains("https://bbb.") || actualLink.contains("https://meet.jit.si/") || actualLink.contains("indigo.collocall.de") || actualLink.contains("public.senfcall.de") || actualLink.contains("facetime.apple.com/join") || actualLink.contains("workplace.com/meet") || actualLink.contains("youcanbook.me/zoom/") || actualLink.contains("fb.workplace.com/groupcall") { if let meetingLink = result.url { return meetingLink } } } } return nil } private func retrieveMeetingURL(_ event: EKEvent) -> URL? { if EventCenter.dataDetector == nil { var dataDetector: NSDataDetector? do { dataDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) } catch { assertionFailure("Unable to create a link-type data detector") return nil } EventCenter.dataDetector = dataDetector } var meetingURL: URL? = nil if let url = event.url { meetingURL = findAppropriateURLs(url.absoluteString) } if let notes = event.notes, meetingURL == nil { meetingURL = findAppropriateURLs(notes) } if let location = event.location, meetingURL == nil { meetingURL = findAppropriateURLs(location) } return meetingURL } private func attendingStatusForUser(_ event: EKEvent) -> EKParticipantStatus { // First check if the current user is the organizer if event.organizer?.isCurrentUser == true { return event.organizer?.participantStatus ?? .unknown } guard let attendes = event.attendees else { return .unknown } for attende in attendes where attende.isCurrentUser { return attende.participantStatus } return .unknown } } struct CalendarInfo { let calendar: EKCalendar var selected: Bool } struct EventInfo { let event: EKEvent let isAllDay: Bool let meetingURL: URL? let attendeStatus: EKParticipantStatus private let nsCalendar = Calendar.autoupdatingCurrent func metadataForMeeting() -> String { let timeIntervalSinceNowForMeeting = event.startDate.timeIntervalSinceNow if timeIntervalSinceNowForMeeting == 0 || event.startDate.shortTimeAgoSinceNow == "0s" { return "started." } else if timeIntervalSinceNowForMeeting < 0, timeIntervalSinceNowForMeeting > -300 { return "started +\(event.startDate.shortTimeAgoSinceNow)." } else if event.startDate.isToday, timeIntervalSinceNowForMeeting > 0 { let timeSince = Date().timeAgo(since: event.startDate).lowercased() let withoutAn = timeSince.replacingOccurrences(of: "an", with: CLEmptyString) var withoutAgo = withoutAn.replacingOccurrences(of: "ago", with: CLEmptyString) // If the user has not turned on seconds granularity for one of the timezones, // we return "in 12 seconds" which looks weird. let upToHours: Set = [.second, .minute, .hour] let difference = nsCalendar.dateComponents(upToHours, from: Date(), to: event.startDate as Date) let minuteDifference = difference.minute ?? 0 let hourDifference = difference.hour ?? 0 if hourDifference > 0, minuteDifference > 0 { withoutAgo.append(contentsOf: "\(minuteDifference)m") } return withoutAgo.contains("seconds") ? "in <1m" : "in \(withoutAgo.lowercased())".trimmingCharacters(in: .whitespaces) } else if event.startDate.isTomorrow { let hoursUntil = event.startDate.hoursUntil return "in \(hoursUntil)h" } return "started." } }