| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- import Foundation
- enum GoogleCalendarClientError: Error {
- case invalidResponse
- case httpStatus(Int, String)
- case decodeFailed(String)
- }
- final class GoogleCalendarClient {
- struct Options: Sendable {
- var daysAhead: Int
- var maxResults: Int
- var includeNonMeetEvents: Bool
- init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
- self.daysAhead = daysAhead
- self.maxResults = maxResults
- self.includeNonMeetEvents = includeNonMeetEvents
- }
- }
- private let session: URLSession
- init(session: URLSession = .shared) {
- self.session = session
- }
- func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
- try await fetchUpcomingMeetings(accessToken: accessToken, options: Options())
- }
- func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
- let now = Date()
- let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let totalLimit = max(1, options.maxResults)
- let pageSize = min(250, totalLimit)
- var nextPageToken: String?
- var meetings: [ScheduledMeeting] = []
- repeat {
- var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
- var queryItems = [
- URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
- URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
- URLQueryItem(name: "singleEvents", value: "true"),
- URLQueryItem(name: "orderBy", value: "startTime"),
- URLQueryItem(name: "maxResults", value: String(pageSize)),
- URLQueryItem(name: "conferenceDataVersion", value: "1")
- ]
- if let nextPageToken {
- queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
- }
- components.queryItems = queryItems
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
- guard (200..<300).contains(http.statusCode) else {
- let body = String(data: data, encoding: .utf8) ?? "<no body>"
- throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
- }
- let decoded: EventsList
- do {
- decoded = try JSONDecoder().decode(EventsList.self, from: data)
- } catch {
- let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
- throw GoogleCalendarClientError.decodeFailed(raw)
- }
- let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
- let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
- let subtitle = item.organizer?.displayName ?? item.organizer?.email
- guard let start = item.start?.resolvedDate,
- let end = item.end?.resolvedDate else { return nil }
- let isAllDay = item.start?.date != nil
- let meetURL: URL? = {
- if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
- let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
- if let uri = entry?.uri, let u = URL(string: uri) { return u }
- if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
- return nil
- }()
- if meetURL == nil, options.includeNonMeetEvents == false { return nil }
- guard let meetURL else { return nil }
- return ScheduledMeeting(
- id: item.id ?? UUID().uuidString,
- title: (title?.isEmpty == false) ? title! : "Untitled meeting",
- subtitle: subtitle,
- startDate: start,
- endDate: end,
- meetURL: meetURL,
- isAllDay: isAllDay
- )
- }
- meetings.append(contentsOf: pageMeetings)
- nextPageToken = decoded.nextPageToken
- } while nextPageToken != nil && meetings.count < totalLimit
- if meetings.count > totalLimit {
- meetings = Array(meetings.prefix(totalLimit))
- }
- return meetings
- }
- }
- extension GoogleCalendarClientError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .invalidResponse:
- return "Google Calendar returned an invalid response."
- case let .httpStatus(status, body):
- return "Google Calendar API error (\(status)): \(body)"
- case let .decodeFailed(raw):
- return "Failed to parse Google Calendar events: \(raw)"
- }
- }
- }
- // MARK: - Calendar API models
- private struct EventsList: Decodable {
- let items: [EventItem]
- let nextPageToken: String?
- }
- private struct EventItem: Decodable {
- let id: String?
- let summary: String?
- let hangoutLink: String?
- let htmlLink: String?
- let organizer: Organizer?
- let start: EventDateTime?
- let end: EventDateTime?
- let conferenceData: ConferenceData?
- }
- private struct Organizer: Decodable {
- let displayName: String?
- let email: String?
- }
- private struct EventDateTime: Decodable {
- let dateTime: String?
- let date: String?
- let timeZone: String?
- var resolvedDate: Date? {
- if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) {
- return parsed
- }
- if let date, let parsed = DateFormatter.googleAllDay.date(from: date) {
- return parsed
- }
- return nil
- }
- private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? {
- if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) {
- return dt
- }
- // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field.
- let formatter = DateFormatter()
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
- if let timeZone, let tz = TimeZone(identifier: timeZone) {
- formatter.timeZone = tz
- } else {
- formatter.timeZone = TimeZone.current
- }
- return formatter.date(from: raw)
- }
- }
- private struct ConferenceData: Decodable {
- let entryPoints: [EntryPoint]?
- }
- private struct EntryPoint: Decodable {
- let entryPointType: String?
- let uri: String?
- }
- private extension ISO8601DateFormatter {
- static let fractional: ISO8601DateFormatter = {
- let f = ISO8601DateFormatter()
- f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return f
- }()
- static let nonFractional: ISO8601DateFormatter = {
- let f = ISO8601DateFormatter()
- f.formatOptions = [.withInternetDateTime]
- return f
- }()
- }
- private extension DateFormatter {
- static let googleAllDay: DateFormatter = {
- let f = DateFormatter()
- f.calendar = Calendar(identifier: .gregorian)
- f.locale = Locale(identifier: "en_US_POSIX")
- f.timeZone = TimeZone(secondsFromGMT: 0)
- f.dateFormat = "yyyy-MM-dd"
- return f
- }()
- }
|