Sin descripción

GoogleClassroomClient.swift 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import Foundation
  2. enum GoogleClassroomClientError: Error {
  3. case invalidResponse
  4. case httpStatus(Int, String)
  5. case decodeFailed(String)
  6. }
  7. /// Minimal Google Classroom REST wrapper for a student's to-do list.
  8. final class GoogleClassroomClient {
  9. struct Options: Sendable {
  10. var maxCourses: Int
  11. var maxCourseWorkPerCourse: Int
  12. init(maxCourses: Int = 20, maxCourseWorkPerCourse: Int = 50) {
  13. self.maxCourses = maxCourses
  14. self.maxCourseWorkPerCourse = maxCourseWorkPerCourse
  15. }
  16. }
  17. private let session: URLSession
  18. init(session: URLSession = .shared) {
  19. self.session = session
  20. }
  21. func fetchTodo(accessToken: String, options: Options? = nil) async throws -> [ClassroomTodoItem] {
  22. let resolvedOptions = options ?? Options()
  23. let courses = try await listActiveCourses(accessToken: accessToken, pageSize: resolvedOptions.maxCourses)
  24. if courses.isEmpty { return [] }
  25. var todos: [ClassroomTodoItem] = []
  26. todos.reserveCapacity(courses.count * 8)
  27. for course in courses.prefix(max(1, resolvedOptions.maxCourses)) {
  28. let courseWork = try await listPublishedCourseWork(
  29. accessToken: accessToken,
  30. courseId: course.id,
  31. pageSize: resolvedOptions.maxCourseWorkPerCourse
  32. )
  33. for work in courseWork {
  34. let due = work.resolvedDueDate
  35. let link = work.alternateLink.flatMap(URL.init(string:))
  36. let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
  37. todos.append(
  38. ClassroomTodoItem(
  39. id: "\(course.id)::\(work.id ?? UUID().uuidString)",
  40. courseId: course.id,
  41. courseName: course.name ?? "Course",
  42. title: (work.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (work.title ?? "Untitled") : "Untitled",
  43. dueDate: due,
  44. alternateLink: link,
  45. workType: workType
  46. )
  47. )
  48. }
  49. }
  50. // Keep only items with a due date near-term first; undated items at end.
  51. todos.sort {
  52. switch ($0.dueDate, $1.dueDate) {
  53. case let (a?, b?):
  54. if a != b { return a < b }
  55. return $0.courseName < $1.courseName
  56. case (_?, nil):
  57. return true
  58. case (nil, _?):
  59. return false
  60. case (nil, nil):
  61. if $0.courseName != $1.courseName { return $0.courseName < $1.courseName }
  62. return $0.title < $1.title
  63. }
  64. }
  65. return todos
  66. }
  67. func fetchEnrolledCourses(accessToken: String, pageSize: Int = 50) async throws -> [ClassroomCourse] {
  68. let courses = try await listActiveCourses(accessToken: accessToken, pageSize: pageSize)
  69. if courses.isEmpty { return [] }
  70. var enrolled: [ClassroomCourse] = []
  71. enrolled.reserveCapacity(courses.count)
  72. for course in courses {
  73. let teachers = try await listCourseTeachers(accessToken: accessToken, courseId: course.id)
  74. let teacherNames = teachers.compactMap { teacher -> String? in
  75. let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines)
  76. if let profileName, profileName.isEmpty == false { return profileName }
  77. let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
  78. if let email, email.isEmpty == false { return email }
  79. return nil
  80. }
  81. enrolled.append(
  82. ClassroomCourse(
  83. id: course.id,
  84. name: (course.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (course.name ?? "Course") : "Course",
  85. section: course.section,
  86. room: course.room,
  87. teacherNames: teacherNames,
  88. enrollmentCode: course.enrollmentCode,
  89. courseState: course.courseState ?? "ACTIVE"
  90. )
  91. )
  92. }
  93. enrolled.sort {
  94. if $0.name != $1.name { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
  95. return $0.id < $1.id
  96. }
  97. return enrolled
  98. }
  99. func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
  100. async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
  101. async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30)
  102. let (announcements, coursework) = try await (announcementsTask, courseworkTask)
  103. let mappedAnnouncements = announcements.map { item in
  104. let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
  105. return ClassroomAnnouncement(
  106. id: item.id ?? UUID().uuidString,
  107. text: (text?.isEmpty == false) ? text! : "Announcement",
  108. postedAt: item.createdDate,
  109. alternateLink: item.alternateLink.flatMap(URL.init(string:)),
  110. attachments: mapMaterialsToAttachments(item.materials)
  111. )
  112. }.sorted { ($0.postedAt ?? .distantPast) > ($1.postedAt ?? .distantPast) }
  113. let mappedCourseWork = coursework.map { work in
  114. let title = work.title?.trimmingCharacters(in: .whitespacesAndNewlines)
  115. let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
  116. return ClassroomCourseWork(
  117. id: work.id ?? UUID().uuidString,
  118. title: (title?.isEmpty == false) ? title! : "Untitled",
  119. subtitle: workType.displayName,
  120. dueDate: work.resolvedDueDate,
  121. alternateLink: work.alternateLink.flatMap(URL.init(string:)),
  122. workType: workType,
  123. attachments: mapMaterialsToAttachments(work.materials)
  124. )
  125. }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
  126. return ClassroomClassDetails(
  127. courseId: courseId,
  128. courseName: courseName,
  129. courseLink: URL(string: "https://classroom.google.com/c/\(courseId)"),
  130. announcements: mappedAnnouncements,
  131. coursework: mappedCourseWork
  132. )
  133. }
  134. // MARK: - Courses
  135. private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
  136. var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses")!
  137. components.queryItems = [
  138. URLQueryItem(name: "studentId", value: "me"),
  139. URLQueryItem(name: "courseStates", value: "ACTIVE"),
  140. URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
  141. ]
  142. var request = URLRequest(url: components.url!)
  143. request.httpMethod = "GET"
  144. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  145. let (data, response) = try await session.data(for: request)
  146. guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
  147. guard (200..<300).contains(http.statusCode) else {
  148. let body = String(data: data, encoding: .utf8) ?? "<no body>"
  149. throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
  150. }
  151. let decoded: CoursesList
  152. do {
  153. decoded = try JSONDecoder().decode(CoursesList.self, from: data)
  154. } catch {
  155. let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
  156. throw GoogleClassroomClientError.decodeFailed(raw)
  157. }
  158. return decoded.courses ?? []
  159. }
  160. private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
  161. var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
  162. components.queryItems = [
  163. URLQueryItem(name: "pageSize", value: "30")
  164. ]
  165. var request = URLRequest(url: components.url!)
  166. request.httpMethod = "GET"
  167. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  168. let (data, response) = try await session.data(for: request)
  169. guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
  170. guard (200..<300).contains(http.statusCode) else {
  171. let body = String(data: data, encoding: .utf8) ?? "<no body>"
  172. throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
  173. }
  174. let decoded: TeachersList
  175. do {
  176. decoded = try JSONDecoder().decode(TeachersList.self, from: data)
  177. } catch {
  178. let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
  179. throw GoogleClassroomClientError.decodeFailed(raw)
  180. }
  181. return decoded.teachers ?? []
  182. }
  183. private func listCourseAnnouncements(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseAnnouncement] {
  184. var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/announcements")!
  185. components.queryItems = [
  186. URLQueryItem(name: "announcementStates", value: "PUBLISHED"),
  187. URLQueryItem(name: "orderBy", value: "updateTime desc"),
  188. URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
  189. ]
  190. var request = URLRequest(url: components.url!)
  191. request.httpMethod = "GET"
  192. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  193. let (data, response) = try await session.data(for: request)
  194. guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
  195. guard (200..<300).contains(http.statusCode) else {
  196. let body = String(data: data, encoding: .utf8) ?? "<no body>"
  197. throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
  198. }
  199. let decoded: CourseAnnouncementList
  200. do {
  201. decoded = try JSONDecoder().decode(CourseAnnouncementList.self, from: data)
  202. } catch {
  203. let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
  204. throw GoogleClassroomClientError.decodeFailed(raw)
  205. }
  206. return decoded.announcements ?? []
  207. }
  208. // MARK: - CourseWork
  209. private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] {
  210. var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/courseWork")!
  211. components.queryItems = [
  212. URLQueryItem(name: "courseWorkStates", value: "PUBLISHED"),
  213. URLQueryItem(name: "orderBy", value: "dueDate desc"),
  214. URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
  215. ]
  216. var request = URLRequest(url: components.url!)
  217. request.httpMethod = "GET"
  218. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  219. let (data, response) = try await session.data(for: request)
  220. guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
  221. guard (200..<300).contains(http.statusCode) else {
  222. let body = String(data: data, encoding: .utf8) ?? "<no body>"
  223. throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
  224. }
  225. let decoded: CourseWorkList
  226. do {
  227. decoded = try JSONDecoder().decode(CourseWorkList.self, from: data)
  228. } catch {
  229. let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
  230. throw GoogleClassroomClientError.decodeFailed(raw)
  231. }
  232. return decoded.courseWork ?? []
  233. }
  234. private func mapMaterialsToAttachments(_ materials: [Material]?) -> [ClassroomAttachment] {
  235. guard let materials else { return [] }
  236. var attachments: [ClassroomAttachment] = []
  237. attachments.reserveCapacity(materials.count)
  238. for (index, material) in materials.enumerated() {
  239. if let drive = material.driveFile?.driveFile,
  240. let linkString = drive.alternateLink,
  241. let url = URL(string: linkString) {
  242. attachments.append(
  243. ClassroomAttachment(
  244. id: drive.id ?? "drive-\(index)",
  245. title: drive.title?.nonEmptyOr("Drive file") ?? "Drive file",
  246. url: url,
  247. mimeType: drive.mimeType,
  248. sourceType: "driveFile"
  249. )
  250. )
  251. } else if let link = material.link,
  252. let linkString = link.url,
  253. let url = URL(string: linkString) {
  254. attachments.append(
  255. ClassroomAttachment(
  256. id: "link-\(index)",
  257. title: link.title?.nonEmptyOr("Link") ?? "Link",
  258. url: url,
  259. mimeType: nil,
  260. sourceType: "link"
  261. )
  262. )
  263. } else if let form = material.form,
  264. let linkString = form.formUrl,
  265. let url = URL(string: linkString) {
  266. attachments.append(
  267. ClassroomAttachment(
  268. id: "form-\(index)",
  269. title: form.title?.nonEmptyOr("Google Form") ?? "Google Form",
  270. url: url,
  271. mimeType: nil,
  272. sourceType: "form"
  273. )
  274. )
  275. } else if let yt = material.youtubeVideo,
  276. let linkString = yt.alternateLink,
  277. let url = URL(string: linkString) {
  278. attachments.append(
  279. ClassroomAttachment(
  280. id: "youtube-\(index)",
  281. title: yt.title?.nonEmptyOr("YouTube") ?? "YouTube",
  282. url: url,
  283. mimeType: nil,
  284. sourceType: "youtubeVideo"
  285. )
  286. )
  287. }
  288. }
  289. return attachments
  290. }
  291. }
  292. extension GoogleClassroomClientError: LocalizedError {
  293. var errorDescription: String? {
  294. switch self {
  295. case .invalidResponse:
  296. return "Google Classroom returned an invalid response."
  297. case let .httpStatus(status, body):
  298. return "Google Classroom API error (\(status)): \(body)"
  299. case let .decodeFailed(raw):
  300. return "Failed to parse Google Classroom response: \(raw)"
  301. }
  302. }
  303. }
  304. // MARK: - REST models (minimal)
  305. private struct CoursesList: Decodable {
  306. let courses: [Course]?
  307. let nextPageToken: String?
  308. }
  309. private struct Course: Decodable {
  310. let id: String
  311. let name: String?
  312. let section: String?
  313. let room: String?
  314. let enrollmentCode: String?
  315. let courseState: String?
  316. }
  317. private struct CourseWorkList: Decodable {
  318. let courseWork: [CourseWork]?
  319. let nextPageToken: String?
  320. }
  321. private struct CourseWork: Decodable {
  322. let id: String?
  323. let title: String?
  324. let alternateLink: String?
  325. let workType: String?
  326. let dueDate: DateParts?
  327. let dueTime: TimeOfDay?
  328. let materials: [Material]?
  329. var resolvedDueDate: Date? {
  330. guard let dueDate else { return nil }
  331. var comps = DateComponents()
  332. comps.calendar = Calendar.current
  333. comps.timeZone = TimeZone.current
  334. comps.year = dueDate.year
  335. comps.month = dueDate.month
  336. comps.day = dueDate.day
  337. if let dueTime {
  338. comps.hour = dueTime.hours
  339. comps.minute = dueTime.minutes
  340. comps.second = dueTime.seconds
  341. } else {
  342. comps.hour = 23
  343. comps.minute = 59
  344. comps.second = 0
  345. }
  346. return comps.date
  347. }
  348. }
  349. private struct CourseAnnouncementList: Decodable {
  350. let announcements: [CourseAnnouncement]?
  351. let nextPageToken: String?
  352. }
  353. private struct CourseAnnouncement: Decodable {
  354. let id: String?
  355. let text: String?
  356. let alternateLink: String?
  357. let creationTime: String?
  358. let materials: [Material]?
  359. var createdDate: Date? {
  360. guard let creationTime else { return nil }
  361. return Date.parseRFC3339(creationTime)
  362. }
  363. }
  364. private struct DateParts: Decodable {
  365. let year: Int?
  366. let month: Int?
  367. let day: Int?
  368. }
  369. private struct TimeOfDay: Decodable {
  370. let hours: Int?
  371. let minutes: Int?
  372. let seconds: Int?
  373. let nanos: Int?
  374. }
  375. private struct Material: Decodable {
  376. let driveFile: SharedDriveFileContainer?
  377. let link: SharedLink?
  378. let form: SharedForm?
  379. let youtubeVideo: SharedYoutubeVideo?
  380. }
  381. private struct SharedDriveFileContainer: Decodable {
  382. let driveFile: SharedDriveFile?
  383. }
  384. private struct SharedDriveFile: Decodable {
  385. let id: String?
  386. let title: String?
  387. let alternateLink: String?
  388. let mimeType: String?
  389. }
  390. private struct SharedLink: Decodable {
  391. let url: String?
  392. let title: String?
  393. }
  394. private struct SharedForm: Decodable {
  395. let formUrl: String?
  396. let title: String?
  397. }
  398. private struct SharedYoutubeVideo: Decodable {
  399. let alternateLink: String?
  400. let title: String?
  401. }
  402. private struct TeachersList: Decodable {
  403. let teachers: [Teacher]?
  404. let nextPageToken: String?
  405. }
  406. private struct Teacher: Decodable {
  407. let profile: UserProfile?
  408. }
  409. private struct UserProfile: Decodable {
  410. let name: UserName?
  411. let emailAddress: String?
  412. }
  413. private struct UserName: Decodable {
  414. let fullName: String?
  415. }
  416. private extension String {
  417. func nonEmptyOr(_ fallback: String) -> String {
  418. let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
  419. return trimmed.isEmpty ? fallback : trimmed
  420. }
  421. }
  422. private extension Date {
  423. static func parseRFC3339(_ text: String) -> Date? {
  424. let formatterWithFractional = ISO8601DateFormatter()
  425. formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  426. if let value = formatterWithFractional.date(from: text) { return value }
  427. let formatter = ISO8601DateFormatter()
  428. formatter.formatOptions = [.withInternetDateTime]
  429. return formatter.date(from: text)
  430. }
  431. }