暫無描述

GoogleCalendarClient.swift 7.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import Foundation
  2. enum GoogleCalendarClientError: Error {
  3. case invalidResponse
  4. case httpStatus(Int, String)
  5. case decodeFailed(String)
  6. }
  7. final class GoogleCalendarClient {
  8. struct Options: Sendable {
  9. var daysAhead: Int
  10. var maxResults: Int
  11. var includeNonMeetEvents: Bool
  12. init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
  13. self.daysAhead = daysAhead
  14. self.maxResults = maxResults
  15. self.includeNonMeetEvents = includeNonMeetEvents
  16. }
  17. }
  18. private let session: URLSession
  19. init(session: URLSession = .shared) {
  20. self.session = session
  21. }
  22. func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
  23. try await fetchUpcomingMeetings(accessToken: accessToken, options: Options())
  24. }
  25. func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
  26. let now = Date()
  27. let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
  28. let formatter = ISO8601DateFormatter()
  29. formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  30. let totalLimit = max(1, options.maxResults)
  31. let pageSize = min(250, totalLimit)
  32. var nextPageToken: String?
  33. var meetings: [ScheduledMeeting] = []
  34. repeat {
  35. var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
  36. var queryItems = [
  37. URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
  38. URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
  39. URLQueryItem(name: "singleEvents", value: "true"),
  40. URLQueryItem(name: "orderBy", value: "startTime"),
  41. URLQueryItem(name: "maxResults", value: String(pageSize)),
  42. URLQueryItem(name: "conferenceDataVersion", value: "1")
  43. ]
  44. if let nextPageToken {
  45. queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
  46. }
  47. components.queryItems = queryItems
  48. var request = URLRequest(url: components.url!)
  49. request.httpMethod = "GET"
  50. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  51. let (data, response) = try await session.data(for: request)
  52. guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
  53. guard (200..<300).contains(http.statusCode) else {
  54. let body = String(data: data, encoding: .utf8) ?? "<no body>"
  55. throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
  56. }
  57. let decoded: EventsList
  58. do {
  59. decoded = try JSONDecoder().decode(EventsList.self, from: data)
  60. } catch {
  61. let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
  62. throw GoogleCalendarClientError.decodeFailed(raw)
  63. }
  64. let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
  65. let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
  66. let subtitle = item.organizer?.displayName ?? item.organizer?.email
  67. guard let start = item.start?.resolvedDate,
  68. let end = item.end?.resolvedDate else { return nil }
  69. let isAllDay = item.start?.date != nil
  70. let meetURL: URL? = {
  71. if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
  72. let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
  73. if let uri = entry?.uri, let u = URL(string: uri) { return u }
  74. if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
  75. return nil
  76. }()
  77. if meetURL == nil, options.includeNonMeetEvents == false { return nil }
  78. guard let meetURL else { return nil }
  79. return ScheduledMeeting(
  80. id: item.id ?? UUID().uuidString,
  81. title: (title?.isEmpty == false) ? title! : "Untitled meeting",
  82. subtitle: subtitle,
  83. startDate: start,
  84. endDate: end,
  85. meetURL: meetURL,
  86. isAllDay: isAllDay
  87. )
  88. }
  89. meetings.append(contentsOf: pageMeetings)
  90. nextPageToken = decoded.nextPageToken
  91. } while nextPageToken != nil && meetings.count < totalLimit
  92. if meetings.count > totalLimit {
  93. meetings = Array(meetings.prefix(totalLimit))
  94. }
  95. return meetings
  96. }
  97. }
  98. extension GoogleCalendarClientError: LocalizedError {
  99. var errorDescription: String? {
  100. switch self {
  101. case .invalidResponse:
  102. return "Google Calendar returned an invalid response."
  103. case let .httpStatus(status, body):
  104. return "Google Calendar API error (\(status)): \(body)"
  105. case let .decodeFailed(raw):
  106. return "Failed to parse Google Calendar events: \(raw)"
  107. }
  108. }
  109. }
  110. // MARK: - Calendar API models
  111. private struct EventsList: Decodable {
  112. let items: [EventItem]
  113. let nextPageToken: String?
  114. }
  115. private struct EventItem: Decodable {
  116. let id: String?
  117. let summary: String?
  118. let hangoutLink: String?
  119. let htmlLink: String?
  120. let organizer: Organizer?
  121. let start: EventDateTime?
  122. let end: EventDateTime?
  123. let conferenceData: ConferenceData?
  124. }
  125. private struct Organizer: Decodable {
  126. let displayName: String?
  127. let email: String?
  128. }
  129. private struct EventDateTime: Decodable {
  130. let dateTime: String?
  131. let date: String?
  132. let timeZone: String?
  133. var resolvedDate: Date? {
  134. if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) {
  135. return parsed
  136. }
  137. if let date, let parsed = DateFormatter.googleAllDay.date(from: date) {
  138. return parsed
  139. }
  140. return nil
  141. }
  142. private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? {
  143. if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) {
  144. return dt
  145. }
  146. // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field.
  147. let formatter = DateFormatter()
  148. formatter.calendar = Calendar(identifier: .gregorian)
  149. formatter.locale = Locale(identifier: "en_US_POSIX")
  150. formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
  151. if let timeZone, let tz = TimeZone(identifier: timeZone) {
  152. formatter.timeZone = tz
  153. } else {
  154. formatter.timeZone = TimeZone.current
  155. }
  156. return formatter.date(from: raw)
  157. }
  158. }
  159. private struct ConferenceData: Decodable {
  160. let entryPoints: [EntryPoint]?
  161. }
  162. private struct EntryPoint: Decodable {
  163. let entryPointType: String?
  164. let uri: String?
  165. }
  166. private extension ISO8601DateFormatter {
  167. static let fractional: ISO8601DateFormatter = {
  168. let f = ISO8601DateFormatter()
  169. f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  170. return f
  171. }()
  172. static let nonFractional: ISO8601DateFormatter = {
  173. let f = ISO8601DateFormatter()
  174. f.formatOptions = [.withInternetDateTime]
  175. return f
  176. }()
  177. }
  178. private extension DateFormatter {
  179. static let googleAllDay: DateFormatter = {
  180. let f = DateFormatter()
  181. f.calendar = Calendar(identifier: .gregorian)
  182. f.locale = Locale(identifier: "en_US_POSIX")
  183. f.timeZone = TimeZone(secondsFromGMT: 0)
  184. f.dateFormat = "yyyy-MM-dd"
  185. return f
  186. }()
  187. }