import Foundation enum GoogleClassroomClientError: Error { case invalidResponse case httpStatus(Int, String) case decodeFailed(String) } /// Minimal Google Classroom REST wrapper for a student's to-do list. final class GoogleClassroomClient { struct Options: Sendable { var maxCourses: Int var maxCourseWorkPerCourse: Int init(maxCourses: Int = 20, maxCourseWorkPerCourse: Int = 50) { self.maxCourses = maxCourses self.maxCourseWorkPerCourse = maxCourseWorkPerCourse } } private let session: URLSession init(session: URLSession = .shared) { self.session = session } func fetchTodo(accessToken: String, options: Options? = nil) async throws -> [ClassroomTodoItem] { let resolvedOptions = options ?? Options() let courses = try await listActiveCourses(accessToken: accessToken, pageSize: resolvedOptions.maxCourses) if courses.isEmpty { return [] } var todos: [ClassroomTodoItem] = [] todos.reserveCapacity(courses.count * 8) for course in courses.prefix(max(1, resolvedOptions.maxCourses)) { let courseWork = try await listPublishedCourseWork( accessToken: accessToken, courseId: course.id, pageSize: resolvedOptions.maxCourseWorkPerCourse ) for work in courseWork { let due = work.resolvedDueDate let link = work.alternateLink.flatMap(URL.init(string:)) let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified todos.append( ClassroomTodoItem( id: "\(course.id)::\(work.id ?? UUID().uuidString)", courseId: course.id, courseName: course.name ?? "Course", title: (work.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (work.title ?? "Untitled") : "Untitled", dueDate: due, alternateLink: link, workType: workType ) ) } } // Keep only items with a due date near-term first; undated items at end. todos.sort { switch ($0.dueDate, $1.dueDate) { case let (a?, b?): if a != b { return a < b } return $0.courseName < $1.courseName case (_?, nil): return true case (nil, _?): return false case (nil, nil): if $0.courseName != $1.courseName { return $0.courseName < $1.courseName } return $0.title < $1.title } } return todos } func fetchEnrolledCourses(accessToken: String, pageSize: Int = 50) async throws -> [ClassroomCourse] { let courses = try await listActiveCourses(accessToken: accessToken, pageSize: pageSize) if courses.isEmpty { return [] } var enrolled: [ClassroomCourse] = [] enrolled.reserveCapacity(courses.count) for course in courses { let teachers = try await listCourseTeachers(accessToken: accessToken, courseId: course.id) let teacherNames = teachers.compactMap { teacher -> String? in let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines) if let profileName, profileName.isEmpty == false { return profileName } let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines) if let email, email.isEmpty == false { return email } return nil } enrolled.append( ClassroomCourse( id: course.id, name: (course.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (course.name ?? "Course") : "Course", section: course.section, room: course.room, teacherNames: teacherNames, enrollmentCode: course.enrollmentCode, courseState: course.courseState ?? "ACTIVE" ) ) } enrolled.sort { if $0.name != $1.name { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } return $0.id < $1.id } return enrolled } func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails { async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30) async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30) let (announcements, coursework) = try await (announcementsTask, courseworkTask) let mappedAnnouncements = announcements.map { item in let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines) return ClassroomAnnouncement( id: item.id ?? UUID().uuidString, text: (text?.isEmpty == false) ? text! : "Announcement", postedAt: item.createdDate, alternateLink: item.alternateLink.flatMap(URL.init(string:)), attachments: mapMaterialsToAttachments(item.materials) ) }.sorted { ($0.postedAt ?? .distantPast) > ($1.postedAt ?? .distantPast) } let mappedCourseWork = coursework.map { work in let title = work.title?.trimmingCharacters(in: .whitespacesAndNewlines) let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified return ClassroomCourseWork( id: work.id ?? UUID().uuidString, title: (title?.isEmpty == false) ? title! : "Untitled", subtitle: workType.displayName, dueDate: work.resolvedDueDate, alternateLink: work.alternateLink.flatMap(URL.init(string:)), workType: workType, attachments: mapMaterialsToAttachments(work.materials) ) }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } return ClassroomClassDetails( courseId: courseId, courseName: courseName, courseLink: URL(string: "https://classroom.google.com/c/\(courseId)"), announcements: mappedAnnouncements, coursework: mappedCourseWork ) } // MARK: - Courses private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] { var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses")! components.queryItems = [ URLQueryItem(name: "studentId", value: "me"), URLQueryItem(name: "courseStates", value: "ACTIVE"), URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize)))) ] 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 GoogleClassroomClientError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.httpStatus(http.statusCode, body) } let decoded: CoursesList do { decoded = try JSONDecoder().decode(CoursesList.self, from: data) } catch { let raw = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.decodeFailed(raw) } return decoded.courses ?? [] } private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] { var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")! components.queryItems = [ URLQueryItem(name: "pageSize", value: "30") ] 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 GoogleClassroomClientError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.httpStatus(http.statusCode, body) } let decoded: TeachersList do { decoded = try JSONDecoder().decode(TeachersList.self, from: data) } catch { let raw = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.decodeFailed(raw) } return decoded.teachers ?? [] } private func listCourseAnnouncements(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseAnnouncement] { var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/announcements")! components.queryItems = [ URLQueryItem(name: "announcementStates", value: "PUBLISHED"), URLQueryItem(name: "orderBy", value: "updateTime desc"), URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize)))) ] 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 GoogleClassroomClientError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.httpStatus(http.statusCode, body) } let decoded: CourseAnnouncementList do { decoded = try JSONDecoder().decode(CourseAnnouncementList.self, from: data) } catch { let raw = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.decodeFailed(raw) } return decoded.announcements ?? [] } // MARK: - CourseWork private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] { var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/courseWork")! components.queryItems = [ URLQueryItem(name: "courseWorkStates", value: "PUBLISHED"), URLQueryItem(name: "orderBy", value: "dueDate desc"), URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize)))) ] 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 GoogleClassroomClientError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.httpStatus(http.statusCode, body) } let decoded: CourseWorkList do { decoded = try JSONDecoder().decode(CourseWorkList.self, from: data) } catch { let raw = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.decodeFailed(raw) } return decoded.courseWork ?? [] } private func mapMaterialsToAttachments(_ materials: [Material]?) -> [ClassroomAttachment] { guard let materials else { return [] } var attachments: [ClassroomAttachment] = [] attachments.reserveCapacity(materials.count) for (index, material) in materials.enumerated() { if let drive = material.driveFile?.driveFile, let linkString = drive.alternateLink, let url = URL(string: linkString) { attachments.append( ClassroomAttachment( id: drive.id ?? "drive-\(index)", title: drive.title?.nonEmptyOr("Drive file") ?? "Drive file", url: url, mimeType: drive.mimeType, sourceType: "driveFile" ) ) } else if let link = material.link, let linkString = link.url, let url = URL(string: linkString) { attachments.append( ClassroomAttachment( id: "link-\(index)", title: link.title?.nonEmptyOr("Link") ?? "Link", url: url, mimeType: nil, sourceType: "link" ) ) } else if let form = material.form, let linkString = form.formUrl, let url = URL(string: linkString) { attachments.append( ClassroomAttachment( id: "form-\(index)", title: form.title?.nonEmptyOr("Google Form") ?? "Google Form", url: url, mimeType: nil, sourceType: "form" ) ) } else if let yt = material.youtubeVideo, let linkString = yt.alternateLink, let url = URL(string: linkString) { attachments.append( ClassroomAttachment( id: "youtube-\(index)", title: yt.title?.nonEmptyOr("YouTube") ?? "YouTube", url: url, mimeType: nil, sourceType: "youtubeVideo" ) ) } } return attachments } } extension GoogleClassroomClientError: LocalizedError { var errorDescription: String? { switch self { case .invalidResponse: return "Google Classroom returned an invalid response." case let .httpStatus(status, body): return "Google Classroom API error (\(status)): \(body)" case let .decodeFailed(raw): return "Failed to parse Google Classroom response: \(raw)" } } } // MARK: - REST models (minimal) private struct CoursesList: Decodable { let courses: [Course]? let nextPageToken: String? } private struct Course: Decodable { let id: String let name: String? let section: String? let room: String? let enrollmentCode: String? let courseState: String? } private struct CourseWorkList: Decodable { let courseWork: [CourseWork]? let nextPageToken: String? } private struct CourseWork: Decodable { let id: String? let title: String? let alternateLink: String? let workType: String? let dueDate: DateParts? let dueTime: TimeOfDay? let materials: [Material]? var resolvedDueDate: Date? { guard let dueDate else { return nil } var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = dueDate.year comps.month = dueDate.month comps.day = dueDate.day if let dueTime { comps.hour = dueTime.hours comps.minute = dueTime.minutes comps.second = dueTime.seconds } else { comps.hour = 23 comps.minute = 59 comps.second = 0 } return comps.date } } private struct CourseAnnouncementList: Decodable { let announcements: [CourseAnnouncement]? let nextPageToken: String? } private struct CourseAnnouncement: Decodable { let id: String? let text: String? let alternateLink: String? let creationTime: String? let materials: [Material]? var createdDate: Date? { guard let creationTime else { return nil } return Date.parseRFC3339(creationTime) } } private struct DateParts: Decodable { let year: Int? let month: Int? let day: Int? } private struct TimeOfDay: Decodable { let hours: Int? let minutes: Int? let seconds: Int? let nanos: Int? } private struct Material: Decodable { let driveFile: SharedDriveFileContainer? let link: SharedLink? let form: SharedForm? let youtubeVideo: SharedYoutubeVideo? } private struct SharedDriveFileContainer: Decodable { let driveFile: SharedDriveFile? } private struct SharedDriveFile: Decodable { let id: String? let title: String? let alternateLink: String? let mimeType: String? } private struct SharedLink: Decodable { let url: String? let title: String? } private struct SharedForm: Decodable { let formUrl: String? let title: String? } private struct SharedYoutubeVideo: Decodable { let alternateLink: String? let title: String? } private struct TeachersList: Decodable { let teachers: [Teacher]? let nextPageToken: String? } private struct Teacher: Decodable { let profile: UserProfile? } private struct UserProfile: Decodable { let name: UserName? let emailAddress: String? } private struct UserName: Decodable { let fullName: String? } private extension String { func nonEmptyOr(_ fallback: String) -> String { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? fallback : trimmed } } private extension Date { static func parseRFC3339(_ text: String) -> Date? { let formatterWithFractional = ISO8601DateFormatter() formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let value = formatterWithFractional.date(from: text) { return value } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: text) } }