diff --git a/CHANGELOG.md b/CHANGELOG.md index da46623be..2aab14eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Gmail: include `gmail.settings.sharing` scope for filter operations to avoid 403 insufficientPermissions. (#69) — thanks @ryanh-ai. - Gmail: resync on stale history 404s and skip missing message fetches without masking non-404 failures. (#70) — thanks @antons. +- Classroom: normalize assignee updates + fix grade update masks. (#74) — thanks @salmonumbrella. ### Build diff --git a/README.md b/README.md index 541e8d49f..35f097e1f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 🧭 gogcli — Google in your terminal. -Google in your terminal — CLI for Gmail, Calendar, Drive, Docs, Slides, Sheets, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). +Google in your terminal — CLI for Gmail, Calendar, Classroom, Drive, Docs, Slides, Sheets, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). ## Features - **Gmail** - search threads, send emails, manage labels, drafts, filters, delegation, vacation settings, and watch (Pub/Sub push) - **Email tracking** - track opens for `gog gmail send --track` with a small Cloudflare Worker backend - **Calendar** - list/create/update events, detect conflicts, manage invitations, check free/busy status, team calendars +- **Classroom** - list courses, rosters, coursework, submissions, announcements, topics, invitations, guardians - **Drive** - list/search/upload/download files, manage permissions, organize folders - **Contacts** - search/create/update contacts, access Workspace directory - **Tasks** - manage tasklists and tasks: create/add/update/done/undo/delete/clear @@ -64,6 +65,7 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console: - Gmail API: https://console.cloud.google.com/apis/api/gmail.googleapis.com - Google Calendar API: https://console.cloud.google.com/apis/api/calendar-json.googleapis.com - Google Drive API: https://console.cloud.google.com/apis/api/drive.googleapis.com + - Google Classroom API: https://console.cloud.google.com/apis/api/classroom.googleapis.com - People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com - Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com - Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com diff --git a/docs/spec.md b/docs/spec.md index 70e4ad2b3..f386da251 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -6,6 +6,7 @@ Build a single, clean, modern Go CLI that talks to: - Gmail API - Google Calendar API +- Google Classroom API - Google Drive API - Google People API (Contacts + directory) @@ -134,7 +135,7 @@ Flag aliases: ### Implemented - `gog auth credentials ` -- `gog auth add [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]` +- `gog auth add [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]` - `gog auth services [--markdown]` - `gog auth keep --key ` (Google Keep; Workspace only) - `gog auth list` @@ -165,6 +166,65 @@ Flag aliases: - `gog calendar delete ` - `gog calendar freebusy --from RFC3339 --to RFC3339` - `gog calendar respond --status accepted|declined|tentative [--send-updates all|none|externalOnly]` +- `gog classroom courses [--state ...] [--max N] [--page TOKEN]` +- `gog classroom courses get ` +- `gog classroom courses create --name NAME [--owner me] [--state ACTIVE|...]` +- `gog classroom courses update [--name ...] [--state ...]` +- `gog classroom courses delete ` +- `gog classroom courses archive ` +- `gog classroom courses unarchive ` +- `gog classroom courses join [--role student|teacher] [--user me]` +- `gog classroom courses leave [--role student|teacher] [--user me]` +- `gog classroom courses url ` +- `gog classroom students [--max N] [--page TOKEN]` +- `gog classroom students get ` +- `gog classroom students add [--enrollment-code CODE]` +- `gog classroom students remove ` +- `gog classroom teachers [--max N] [--page TOKEN]` +- `gog classroom teachers get ` +- `gog classroom teachers add ` +- `gog classroom teachers remove ` +- `gog classroom roster [--students] [--teachers]` +- `gog classroom coursework [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]` +- `gog classroom coursework get ` +- `gog classroom coursework create --title TITLE [--type ASSIGNMENT|...]` +- `gog classroom coursework update [--title ...]` +- `gog classroom coursework delete ` +- `gog classroom coursework assignees [--mode ...] [--add-student ...]` +- `gog classroom materials [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]` +- `gog classroom materials get ` +- `gog classroom materials create --title TITLE` +- `gog classroom materials update [--title ...]` +- `gog classroom materials delete ` +- `gog classroom submissions [--state ...] [--max N] [--page TOKEN]` +- `gog classroom submissions get ` +- `gog classroom submissions turn-in ` +- `gog classroom submissions reclaim ` +- `gog classroom submissions return ` +- `gog classroom submissions grade [--draft N] [--assigned N]` +- `gog classroom announcements [--state ...] [--max N] [--page TOKEN]` +- `gog classroom announcements get ` +- `gog classroom announcements create --text TEXT` +- `gog classroom announcements update [--text ...]` +- `gog classroom announcements delete ` +- `gog classroom announcements assignees [--mode ...]` +- `gog classroom topics [--max N] [--page TOKEN]` +- `gog classroom topics get ` +- `gog classroom topics create --name NAME` +- `gog classroom topics update --name NAME` +- `gog classroom topics delete ` +- `gog classroom invitations [--course ID] [--user ID]` +- `gog classroom invitations get ` +- `gog classroom invitations create --role STUDENT|TEACHER|OWNER` +- `gog classroom invitations accept ` +- `gog classroom invitations delete ` +- `gog classroom guardians [--max N] [--page TOKEN]` +- `gog classroom guardians get ` +- `gog classroom guardians delete ` +- `gog classroom guardian-invitations [--state ...] [--max N] [--page TOKEN]` +- `gog classroom guardian-invitations get ` +- `gog classroom guardian-invitations create --email EMAIL` +- `gog classroom profile [userId]` - `gog gmail search [--max N] [--page TOKEN]` - `gog gmail thread get [--download]` - `gog gmail thread modify [--add ...] [--remove ...]` diff --git a/internal/cmd/classroom.go b/internal/cmd/classroom.go new file mode 100644 index 000000000..da805fb67 --- /dev/null +++ b/internal/cmd/classroom.go @@ -0,0 +1,21 @@ +package cmd + +import "github.com/steipete/gogcli/internal/googleapi" + +var newClassroomService = googleapi.NewClassroom + +type ClassroomCmd struct { + Courses ClassroomCoursesCmd `cmd:"" help:"Courses"` + Students ClassroomStudentsCmd `cmd:"" help:"Course students"` + Teachers ClassroomTeachersCmd `cmd:"" help:"Course teachers"` + Roster ClassroomRosterCmd `cmd:"" help:"Course roster (students + teachers)"` + Coursework ClassroomCourseworkCmd `cmd:"" name:"coursework" aliases:"work" help:"Coursework"` + Materials ClassroomMaterialsCmd `cmd:"" name:"materials" help:"Coursework materials"` + Submissions ClassroomSubmissionsCmd `cmd:"" help:"Student submissions"` + Announcements ClassroomAnnouncementsCmd `cmd:"" help:"Announcements"` + Topics ClassroomTopicsCmd `cmd:"" help:"Topics"` + Invitations ClassroomInvitationsCmd `cmd:"" help:"Invitations"` + Guardians ClassroomGuardiansCmd `cmd:"" help:"Guardians"` + GuardianInvites ClassroomGuardianInvitesCmd `cmd:"" name:"guardian-invitations" help:"Guardian invitations"` + Profile ClassroomProfileCmd `cmd:"" help:"User profiles"` +} diff --git a/internal/cmd/classroom_announcements.go b/internal/cmd/classroom_announcements.go new file mode 100644 index 000000000..3c62cd85d --- /dev/null +++ b/internal/cmd/classroom_announcements.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomAnnouncementsCmd struct { + List ClassroomAnnouncementsListCmd `cmd:"" default:"withargs" help:"List announcements"` + Get ClassroomAnnouncementsGetCmd `cmd:"" help:"Get an announcement"` + Create ClassroomAnnouncementsCreateCmd `cmd:"" help:"Create an announcement"` + Update ClassroomAnnouncementsUpdateCmd `cmd:"" help:"Update an announcement"` + Delete ClassroomAnnouncementsDeleteCmd `cmd:"" help:"Delete an announcement" aliases:"rm"` + Assignees ClassroomAnnouncementsAssigneesCmd `cmd:"" name:"assignees" help:"Modify announcement assignees"` +} + +type ClassroomAnnouncementsListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + States string `name:"state" help:"Announcement states filter (comma-separated: DRAFT,PUBLISHED,DELETED)"` + OrderBy string `name:"order-by" help:"Order by (e.g., updateTime desc)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomAnnouncementsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Courses.Announcements.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.AnnouncementStates(upper...) + } + if v := strings.TrimSpace(c.OrderBy); v != "" { + call.OrderBy(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "announcements": resp.Announcements, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Announcements) == 0 { + u.Err().Println("No announcements") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tSTATE\tTEXT\tSCHEDULED\tUPDATED") + for _, ann := range resp.Announcements { + if ann == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(ann.Id), + sanitizeTab(ann.State), + sanitizeTab(truncateClassroomText(ann.Text, 50)), + sanitizeTab(ann.ScheduledTime), + sanitizeTab(ann.UpdateTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomAnnouncementsGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + AnnouncementID string `arg:"" name:"announcementId" help:"Announcement ID"` +} + +func (c *ClassroomAnnouncementsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + announcementID := strings.TrimSpace(c.AnnouncementID) + if courseID == "" { + return usage("empty courseId") + } + if announcementID == "" { + return usage("empty announcementId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + ann, err := svc.Courses.Announcements.Get(courseID, announcementID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"announcement": ann}) + } + + u.Out().Printf("id\t%s", ann.Id) + u.Out().Printf("state\t%s", ann.State) + if ann.Text != "" { + u.Out().Printf("text\t%s", ann.Text) + } + if ann.ScheduledTime != "" { + u.Out().Printf("scheduled\t%s", ann.ScheduledTime) + } + if ann.AlternateLink != "" { + u.Out().Printf("link\t%s", ann.AlternateLink) + } + return nil +} + +type ClassroomAnnouncementsCreateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Text string `name:"text" help:"Announcement text" required:""` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` +} + +func (c *ClassroomAnnouncementsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + if strings.TrimSpace(c.Text) == "" { + return usage("empty text") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + ann := &classroom.Announcement{Text: strings.TrimSpace(c.Text)} + if v := strings.TrimSpace(c.State); v != "" { + ann.State = strings.ToUpper(v) + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + ann.ScheduledTime = v + } + + created, err := svc.Courses.Announcements.Create(courseID, ann).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"announcement": created}) + } + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("state\t%s", created.State) + return nil +} + +type ClassroomAnnouncementsUpdateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + AnnouncementID string `arg:"" name:"announcementId" help:"Announcement ID"` + Text string `name:"text" help:"Announcement text"` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` +} + +func (c *ClassroomAnnouncementsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + announcementID := strings.TrimSpace(c.AnnouncementID) + if courseID == "" { + return usage("empty courseId") + } + if announcementID == "" { + return usage("empty announcementId") + } + + ann := &classroom.Announcement{} + fields := make([]string, 0, 4) + if v := strings.TrimSpace(c.Text); v != "" { + ann.Text = v + fields = append(fields, "text") + } + if v := strings.TrimSpace(c.State); v != "" { + ann.State = strings.ToUpper(v) + fields = append(fields, "state") + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + ann.ScheduledTime = v + fields = append(fields, "scheduledTime") + } + if len(fields) == 0 { + return usage("no updates specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.Announcements.Patch(courseID, announcementID, ann).UpdateMask(updateMask(fields)).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"announcement": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("state\t%s", updated.State) + return nil +} + +type ClassroomAnnouncementsDeleteCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + AnnouncementID string `arg:"" name:"announcementId" help:"Announcement ID"` +} + +func (c *ClassroomAnnouncementsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + announcementID := strings.TrimSpace(c.AnnouncementID) + if courseID == "" { + return usage("empty courseId") + } + if announcementID == "" { + return usage("empty announcementId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete announcement %s from %s", announcementID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.Announcements.Delete(courseID, announcementID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "courseId": courseID, + "announcementId": announcementID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("announcement_id\t%s", announcementID) + return nil +} + +type ClassroomAnnouncementsAssigneesCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + AnnouncementID string `arg:"" name:"announcementId" help:"Announcement ID"` + Mode string `name:"mode" help:"Assignee mode: ALL_STUDENTS, INDIVIDUAL_STUDENTS"` + AddStudents []string `name:"add-student" help:"Student IDs to add" sep:","` + RemoveStudents []string `name:"remove-student" help:"Student IDs to remove" sep:","` +} + +func (c *ClassroomAnnouncementsAssigneesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + announcementID := strings.TrimSpace(c.AnnouncementID) + if courseID == "" { + return usage("empty courseId") + } + if announcementID == "" { + return usage("empty announcementId") + } + + mode, opts, err := normalizeAssigneeMode(c.Mode, c.AddStudents, c.RemoveStudents) + if err != nil { + return usage(err.Error()) + } + req := &classroom.ModifyAnnouncementAssigneesRequest{ + AssigneeMode: mode, + ModifyIndividualStudentsOptions: opts, + } + if req.AssigneeMode == "" && req.ModifyIndividualStudentsOptions == nil { + return usage("no assignee changes specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.Announcements.ModifyAssignees(courseID, announcementID, req).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"announcement": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("assignee_mode\t%s", updated.AssigneeMode) + return nil +} + +func truncateClassroomText(s string, maxLen int) string { + s = strings.TrimSpace(s) + if s == "" || maxLen <= 0 { + return s + } + r := []rune(s) + if len(r) <= maxLen { + return s + } + return string(r[:maxLen]) + "..." +} diff --git a/internal/cmd/classroom_courses.go b/internal/cmd/classroom_courses.go new file mode 100644 index 000000000..6d8c07119 --- /dev/null +++ b/internal/cmd/classroom_courses.go @@ -0,0 +1,553 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomCoursesCmd struct { + List ClassroomCoursesListCmd `cmd:"" default:"withargs" help:"List courses"` + Get ClassroomCoursesGetCmd `cmd:"" help:"Get a course"` + Create ClassroomCoursesCreateCmd `cmd:"" help:"Create a course"` + Update ClassroomCoursesUpdateCmd `cmd:"" help:"Update a course"` + Delete ClassroomCoursesDeleteCmd `cmd:"" help:"Delete a course" aliases:"rm"` + Archive ClassroomCoursesArchiveCmd `cmd:"" help:"Archive a course"` + Unarchive ClassroomCoursesUnarchiveCmd `cmd:"" help:"Unarchive a course"` + Join ClassroomCoursesJoinCmd `cmd:"" help:"Join a course"` + Leave ClassroomCoursesLeaveCmd `cmd:"" help:"Leave a course"` + URL ClassroomCoursesURLCmd `cmd:"" name:"url" help:"Print Classroom web URLs for courses"` +} + +type ClassroomCoursesListCmd struct { + States string `name:"state" help:"Course states filter (comma-separated: ACTIVE,ARCHIVED,PROVISIONED,DECLINED)"` + TeacherID string `name:"teacher" help:"Filter by teacher user ID or email"` + StudentID string `name:"student" help:"Filter by student user ID or email"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomCoursesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Courses.List().PageSize(c.Max).PageToken(c.Page).Context(ctx) + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.CourseStates(upper...) + } + if v := strings.TrimSpace(c.TeacherID); v != "" { + call.TeacherId(v) + } + if v := strings.TrimSpace(c.StudentID); v != "" { + call.StudentId(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "courses": resp.Courses, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Courses) == 0 { + u.Err().Println("No courses") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tSECTION\tSTATE\tOWNER") + for _, course := range resp.Courses { + if course == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(course.Id), + sanitizeTab(course.Name), + sanitizeTab(course.Section), + sanitizeTab(course.CourseState), + sanitizeTab(course.OwnerId), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomCoursesGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` +} + +func (c *ClassroomCoursesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + course, err := svc.Courses.Get(courseID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"course": course}) + } + + u.Out().Printf("id\t%s", course.Id) + u.Out().Printf("name\t%s", course.Name) + if course.Section != "" { + u.Out().Printf("section\t%s", course.Section) + } + if course.DescriptionHeading != "" { + u.Out().Printf("description_heading\t%s", course.DescriptionHeading) + } + if course.Description != "" { + u.Out().Printf("description\t%s", course.Description) + } + if course.Room != "" { + u.Out().Printf("room\t%s", course.Room) + } + u.Out().Printf("state\t%s", course.CourseState) + if course.OwnerId != "" { + u.Out().Printf("owner\t%s", course.OwnerId) + } + if course.EnrollmentCode != "" { + u.Out().Printf("enrollment_code\t%s", course.EnrollmentCode) + } + if course.AlternateLink != "" { + u.Out().Printf("link\t%s", course.AlternateLink) + } + return nil +} + +type ClassroomCoursesCreateCmd struct { + Name string `name:"name" help:"Course name" required:""` + OwnerID string `name:"owner" help:"Owner user ID or email" default:"me"` + Section string `name:"section" help:"Section"` + DescriptionHeading string `name:"description-heading" help:"Description heading"` + Description string `name:"description" help:"Description"` + Room string `name:"room" help:"Room"` + State string `name:"state" help:"Course state (ACTIVE, ARCHIVED, PROVISIONED, DECLINED)"` +} + +func (c *ClassroomCoursesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("empty name") + } + owner := strings.TrimSpace(c.OwnerID) + if owner == "" { + return usage("empty owner") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + course := &classroom.Course{ + Name: name, + OwnerId: owner, + } + if v := strings.TrimSpace(c.Section); v != "" { + course.Section = v + } + if v := strings.TrimSpace(c.DescriptionHeading); v != "" { + course.DescriptionHeading = v + } + if v := strings.TrimSpace(c.Description); v != "" { + course.Description = v + } + if v := strings.TrimSpace(c.Room); v != "" { + course.Room = v + } + if v := strings.TrimSpace(c.State); v != "" { + course.CourseState = strings.ToUpper(v) + } + + created, err := svc.Courses.Create(course).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"course": created}) + } + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("name\t%s", created.Name) + u.Out().Printf("state\t%s", created.CourseState) + u.Out().Printf("owner\t%s", created.OwnerId) + return nil +} + +type ClassroomCoursesUpdateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Name string `name:"name" help:"Course name"` + OwnerID string `name:"owner" help:"Owner user ID or email"` + Section string `name:"section" help:"Section"` + DescriptionHeading string `name:"description-heading" help:"Description heading"` + Description string `name:"description" help:"Description"` + Room string `name:"room" help:"Room"` + State string `name:"state" help:"Course state (ACTIVE, ARCHIVED, PROVISIONED, DECLINED)"` +} + +func (c *ClassroomCoursesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + course := &classroom.Course{} + fields := make([]string, 0, 6) + + if v := strings.TrimSpace(c.Name); v != "" { + course.Name = v + fields = append(fields, "name") + } + if v := strings.TrimSpace(c.OwnerID); v != "" { + course.OwnerId = v + fields = append(fields, "ownerId") + } + if v := strings.TrimSpace(c.Section); v != "" { + course.Section = v + fields = append(fields, "section") + } + if v := strings.TrimSpace(c.DescriptionHeading); v != "" { + course.DescriptionHeading = v + fields = append(fields, "descriptionHeading") + } + if v := strings.TrimSpace(c.Description); v != "" { + course.Description = v + fields = append(fields, "description") + } + if v := strings.TrimSpace(c.Room); v != "" { + course.Room = v + fields = append(fields, "room") + } + if v := strings.TrimSpace(c.State); v != "" { + course.CourseState = strings.ToUpper(v) + fields = append(fields, "courseState") + } + + if len(fields) == 0 { + return usage("no updates specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.Patch(courseID, course).UpdateMask(updateMask(fields)).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"course": updated}) + } + u := ui.FromContext(ctx) + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("name\t%s", updated.Name) + u.Out().Printf("state\t%s", updated.CourseState) + return nil +} + +type ClassroomCoursesDeleteCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` +} + +func (c *ClassroomCoursesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete course %s", courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.Delete(courseID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "courseId": courseID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("course_id\t%s", courseID) + return nil +} + +type ClassroomCoursesArchiveCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` +} + +func (c *ClassroomCoursesArchiveCmd) Run(ctx context.Context, flags *RootFlags) error { + return updateCourseState(ctx, flags, c.CourseID, "ARCHIVED") +} + +type ClassroomCoursesUnarchiveCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` +} + +func (c *ClassroomCoursesUnarchiveCmd) Run(ctx context.Context, flags *RootFlags) error { + return updateCourseState(ctx, flags, c.CourseID, "ACTIVE") +} + +func updateCourseState(ctx context.Context, flags *RootFlags, courseID, state string) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID = strings.TrimSpace(courseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + course := &classroom.Course{CourseState: state} + updated, err := svc.Courses.Patch(courseID, course).UpdateMask("courseState").Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"course": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("state\t%s", updated.CourseState) + return nil +} + +type ClassroomCoursesJoinCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Role string `name:"role" help:"Role to join as: student|teacher" default:"student"` + UserID string `name:"user" help:"User ID or email to join" default:"me"` + EnrollmentCode string `name:"enrollment-code" help:"Enrollment code (student joins only)"` +} + +func (c *ClassroomCoursesJoinCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + role := strings.ToLower(strings.TrimSpace(c.Role)) + userID := strings.TrimSpace(c.UserID) + if userID == "" { + return usage("empty user") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + switch role { + case "student": + student := &classroom.Student{UserId: userID} + call := svc.Courses.Students.Create(courseID, student).Context(ctx) + if code := strings.TrimSpace(c.EnrollmentCode); code != "" { + call.EnrollmentCode(code) + } + created, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"student": created}) + } + u.Out().Printf("user_id\t%s", created.UserId) + u.Out().Printf("email\t%s", profileEmail(created.Profile)) + u.Out().Printf("name\t%s", profileName(created.Profile)) + return nil + case "teacher": + teacher := &classroom.Teacher{UserId: userID} + created, err := svc.Courses.Teachers.Create(courseID, teacher).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"teacher": created}) + } + u.Out().Printf("user_id\t%s", created.UserId) + u.Out().Printf("email\t%s", profileEmail(created.Profile)) + u.Out().Printf("name\t%s", profileName(created.Profile)) + return nil + default: + return usagef("invalid role %q (expected student or teacher)", role) + } +} + +type ClassroomCoursesLeaveCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Role string `name:"role" help:"Role to remove: student|teacher" default:"student"` + UserID string `name:"user" help:"User ID or email to remove" default:"me"` +} + +func (c *ClassroomCoursesLeaveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + role := strings.ToLower(strings.TrimSpace(c.Role)) + userID := strings.TrimSpace(c.UserID) + if userID == "" { + return usage("empty user") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + switch role { + case "student": + if _, err := svc.Courses.Students.Delete(courseID, userID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + case "teacher": + if _, err := svc.Courses.Teachers.Delete(courseID, userID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + default: + return usagef("invalid role %q (expected student or teacher)", role) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "removed": true, + "courseId": courseID, + "userId": userID, + "role": role, + }) + } + u.Out().Printf("removed\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("user_id\t%s", userID) + u.Out().Printf("role\t%s", role) + return nil +} + +type ClassroomCoursesURLCmd struct { + CourseIDs []string `arg:"" name:"courseId" help:"Course IDs or aliases"` +} + +func (c *ClassroomCoursesURLCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if len(c.CourseIDs) == 0 { + return usage("missing courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + urls := make([]map[string]string, 0, len(c.CourseIDs)) + for _, id := range c.CourseIDs { + link, err := classroomCourseLink(ctx, svc, id) + if err != nil { + return err + } + urls = append(urls, map[string]string{"id": id, "url": link}) + } + return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls}) + } + + for _, id := range c.CourseIDs { + link, err := classroomCourseLink(ctx, svc, id) + if err != nil { + return err + } + u.Out().Printf("%s\t%s", id, link) + } + return nil +} + +func classroomCourseLink(ctx context.Context, svc *classroom.Service, courseID string) (string, error) { + id := strings.TrimSpace(courseID) + if id == "" { + return "", usage("empty courseId") + } + course, err := svc.Courses.Get(id).Context(ctx).Do() + if err != nil { + return "", wrapClassroomError(err) + } + return course.AlternateLink, nil +} diff --git a/internal/cmd/classroom_coursework.go b/internal/cmd/classroom_coursework.go new file mode 100644 index 000000000..3fd715ea3 --- /dev/null +++ b/internal/cmd/classroom_coursework.go @@ -0,0 +1,477 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomCourseworkCmd struct { + List ClassroomCourseworkListCmd `cmd:"" default:"withargs" help:"List coursework"` + Get ClassroomCourseworkGetCmd `cmd:"" help:"Get coursework"` + Create ClassroomCourseworkCreateCmd `cmd:"" help:"Create coursework"` + Update ClassroomCourseworkUpdateCmd `cmd:"" help:"Update coursework"` + Delete ClassroomCourseworkDeleteCmd `cmd:"" help:"Delete coursework" aliases:"rm"` + Assignees ClassroomCourseworkAssigneesCmd `cmd:"" name:"assignees" help:"Modify coursework assignees"` +} + +type ClassroomCourseworkListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + States string `name:"state" help:"Coursework states filter (comma-separated: DRAFT,PUBLISHED,DELETED)"` + Topic string `name:"topic" help:"Filter by topic ID"` + OrderBy string `name:"order-by" help:"Order by (e.g., updateTime desc, dueDate desc)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Courses.CourseWork.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.CourseWorkStates(upper...) + } + if v := strings.TrimSpace(c.OrderBy); v != "" { + call.OrderBy(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + // Client-side filter by topic (API doesn't support server-side topic filter) + topicFilter := strings.TrimSpace(c.Topic) + coursework := resp.CourseWork + if topicFilter != "" { + filtered := make([]*classroom.CourseWork, 0, len(coursework)) + for _, work := range coursework { + if work != nil && work.TopicId == topicFilter { + filtered = append(filtered, work) + } + } + coursework = filtered + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "coursework": coursework, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(coursework) == 0 { + u.Err().Println("No coursework") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTITLE\tSTATE\tDUE\tTYPE\tMAX_POINTS") + for _, work := range coursework { + if work == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(work.Id), + sanitizeTab(work.Title), + sanitizeTab(work.State), + sanitizeTab(formatClassroomDue(work.DueDate, work.DueTime)), + sanitizeTab(work.WorkType), + formatFloatValue(work.MaxPoints), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomCourseworkGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` +} + +func (c *ClassroomCourseworkGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + work, err := svc.Courses.CourseWork.Get(courseID, courseworkID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"coursework": work}) + } + + u.Out().Printf("id\t%s", work.Id) + u.Out().Printf("title\t%s", work.Title) + if work.Description != "" { + u.Out().Printf("description\t%s", work.Description) + } + u.Out().Printf("state\t%s", work.State) + u.Out().Printf("type\t%s", work.WorkType) + if due := formatClassroomDue(work.DueDate, work.DueTime); due != "" { + u.Out().Printf("due\t%s", due) + } + if work.ScheduledTime != "" { + u.Out().Printf("scheduled\t%s", work.ScheduledTime) + } + if work.TopicId != "" { + u.Out().Printf("topic_id\t%s", work.TopicId) + } + if work.MaxPoints != 0 { + u.Out().Printf("max_points\t%s", formatFloatValue(work.MaxPoints)) + } + if work.AlternateLink != "" { + u.Out().Printf("link\t%s", work.AlternateLink) + } + return nil +} + +type ClassroomCourseworkCreateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Title string `name:"title" help:"Title" required:""` + Description string `name:"description" help:"Description"` + WorkType string `name:"type" help:"Work type: ASSIGNMENT, SHORT_ANSWER_QUESTION, MULTIPLE_CHOICE_QUESTION" default:"ASSIGNMENT"` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + MaxPoints float64 `name:"max-points" help:"Max points"` + Due string `name:"due" help:"Due date/time (RFC3339 or YYYY-MM-DD [HH:MM])"` + DueDate string `name:"due-date" help:"Due date (YYYY-MM-DD)"` + DueTime string `name:"due-time" help:"Due time (HH:MM or HH:MM:SS)"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` + TopicID string `name:"topic" help:"Topic ID"` +} + +func (c *ClassroomCourseworkCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + if strings.TrimSpace(c.Title) == "" { + return usage("empty title") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + work := &classroom.CourseWork{ + Title: strings.TrimSpace(c.Title), + Description: strings.TrimSpace(c.Description), + WorkType: strings.ToUpper(strings.TrimSpace(c.WorkType)), + } + if v := strings.TrimSpace(c.State); v != "" { + work.State = strings.ToUpper(v) + } + if c.MaxPoints != 0 { + work.MaxPoints = c.MaxPoints + } + if v := strings.TrimSpace(c.TopicID); v != "" { + work.TopicId = v + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + work.ScheduledTime = v + } + + var dueDate *classroom.Date + var dueTime *classroom.TimeOfDay + if strings.TrimSpace(c.Due) != "" { + dueDate, dueTime, err = parseClassroomDue(c.Due) + if err != nil { + return usage(err.Error()) + } + } else { + if strings.TrimSpace(c.DueDate) != "" { + dueDate, err = parseClassroomDate(c.DueDate) + if err != nil { + return usage(err.Error()) + } + } + if strings.TrimSpace(c.DueTime) != "" { + dueTime, err = parseClassroomTime(c.DueTime) + if err != nil { + return usage(err.Error()) + } + } + } + if dueTime != nil && dueDate == nil { + return usage("due time requires a due date") + } + if dueDate != nil { + work.DueDate = dueDate + } + if dueTime != nil { + work.DueTime = dueTime + } + + created, err := svc.Courses.CourseWork.Create(courseID, work).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"coursework": created}) + } + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("title\t%s", created.Title) + u.Out().Printf("state\t%s", created.State) + return nil +} + +type ClassroomCourseworkUpdateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + Title string `name:"title" help:"Title"` + Description string `name:"description" help:"Description"` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + MaxPoints float64 `name:"max-points" help:"Max points"` + Due string `name:"due" help:"Due date/time (RFC3339 or YYYY-MM-DD [HH:MM])"` + DueDate string `name:"due-date" help:"Due date (YYYY-MM-DD)"` + DueTime string `name:"due-time" help:"Due time (HH:MM or HH:MM:SS)"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` + TopicID string `name:"topic" help:"Topic ID"` +} + +func (c *ClassroomCourseworkUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + + work := &classroom.CourseWork{} + fields := make([]string, 0, 6) + + if v := strings.TrimSpace(c.Title); v != "" { + work.Title = v + fields = append(fields, "title") + } + if v := strings.TrimSpace(c.Description); v != "" { + work.Description = v + fields = append(fields, "description") + } + if v := strings.TrimSpace(c.State); v != "" { + work.State = strings.ToUpper(v) + fields = append(fields, "state") + } + if c.MaxPoints != 0 { + work.MaxPoints = c.MaxPoints + fields = append(fields, "maxPoints") + } + if v := strings.TrimSpace(c.TopicID); v != "" { + work.TopicId = v + fields = append(fields, "topicId") + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + work.ScheduledTime = v + fields = append(fields, "scheduledTime") + } + + var dueDate *classroom.Date + var dueTime *classroom.TimeOfDay + if strings.TrimSpace(c.Due) != "" { + dueDate, dueTime, err = parseClassroomDue(c.Due) + if err != nil { + return usage(err.Error()) + } + } else { + if strings.TrimSpace(c.DueDate) != "" { + dueDate, err = parseClassroomDate(c.DueDate) + if err != nil { + return usage(err.Error()) + } + } + if strings.TrimSpace(c.DueTime) != "" { + dueTime, err = parseClassroomTime(c.DueTime) + if err != nil { + return usage(err.Error()) + } + } + } + if dueTime != nil && dueDate == nil { + return usage("due time requires a due date") + } + if dueDate != nil { + work.DueDate = dueDate + fields = append(fields, "dueDate") + } + if dueTime != nil { + work.DueTime = dueTime + fields = append(fields, "dueTime") + } + + if len(fields) == 0 { + return usage("no updates specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.CourseWork.Patch(courseID, courseworkID, work).UpdateMask(updateMask(fields)).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"coursework": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("title\t%s", updated.Title) + u.Out().Printf("state\t%s", updated.State) + return nil +} + +type ClassroomCourseworkDeleteCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` +} + +func (c *ClassroomCourseworkDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete coursework %s from %s", courseworkID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.CourseWork.Delete(courseID, courseworkID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "courseId": courseID, + "courseworkId": courseworkID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("coursework_id\t%s", courseworkID) + return nil +} + +type ClassroomCourseworkAssigneesCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + Mode string `name:"mode" help:"Assignee mode: ALL_STUDENTS, INDIVIDUAL_STUDENTS"` + AddStudents []string `name:"add-student" help:"Student IDs to add" sep:","` + RemoveStudents []string `name:"remove-student" help:"Student IDs to remove" sep:","` +} + +func (c *ClassroomCourseworkAssigneesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + + mode, opts, err := normalizeAssigneeMode(c.Mode, c.AddStudents, c.RemoveStudents) + if err != nil { + return usage(err.Error()) + } + req := &classroom.ModifyCourseWorkAssigneesRequest{ + AssigneeMode: mode, + ModifyIndividualStudentsOptions: opts, + } + if req.AssigneeMode == "" && req.ModifyIndividualStudentsOptions == nil { + return usage("no assignee changes specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.CourseWork.ModifyAssignees(courseID, courseworkID, req).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"coursework": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("assignee_mode\t%s", updated.AssigneeMode) + return nil +} diff --git a/internal/cmd/classroom_guardians.go b/internal/cmd/classroom_guardians.go new file mode 100644 index 000000000..f29baa16d --- /dev/null +++ b/internal/cmd/classroom_guardians.go @@ -0,0 +1,330 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomGuardiansCmd struct { + List ClassroomGuardiansListCmd `cmd:"" default:"withargs" help:"List guardians"` + Get ClassroomGuardiansGetCmd `cmd:"" help:"Get a guardian"` + Delete ClassroomGuardiansDeleteCmd `cmd:"" help:"Delete a guardian" aliases:"rm"` +} + +type ClassroomGuardiansListCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + Email string `name:"email" help:"Filter by invited email address"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomGuardiansListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + if studentID == "" { + return usage("empty studentId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.UserProfiles.Guardians.List(studentID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if v := strings.TrimSpace(c.Email); v != "" { + call.InvitedEmailAddress(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "guardians": resp.Guardians, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Guardians) == 0 { + u.Err().Println("No guardians") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "GUARDIAN_ID\tEMAIL\tNAME") + for _, guardian := range resp.Guardians { + if guardian == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(guardian.GuardianId), + sanitizeTab(profileEmail(guardian.GuardianProfile)), + sanitizeTab(profileName(guardian.GuardianProfile)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomGuardiansGetCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + GuardianID string `arg:"" name:"guardianId" help:"Guardian ID"` +} + +func (c *ClassroomGuardiansGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + guardianID := strings.TrimSpace(c.GuardianID) + if studentID == "" { + return usage("empty studentId") + } + if guardianID == "" { + return usage("empty guardianId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + guardian, err := svc.UserProfiles.Guardians.Get(studentID, guardianID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"guardian": guardian}) + } + + u.Out().Printf("id\t%s", guardian.GuardianId) + u.Out().Printf("student_id\t%s", guardian.StudentId) + u.Out().Printf("email\t%s", profileEmail(guardian.GuardianProfile)) + u.Out().Printf("name\t%s", profileName(guardian.GuardianProfile)) + return nil +} + +type ClassroomGuardiansDeleteCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + GuardianID string `arg:"" name:"guardianId" help:"Guardian ID"` +} + +func (c *ClassroomGuardiansDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + guardianID := strings.TrimSpace(c.GuardianID) + if studentID == "" { + return usage("empty studentId") + } + if guardianID == "" { + return usage("empty guardianId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete guardian %s for student %s", guardianID, studentID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.UserProfiles.Guardians.Delete(studentID, guardianID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "studentId": studentID, + "guardianId": guardianID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("student_id\t%s", studentID) + u.Out().Printf("guardian_id\t%s", guardianID) + return nil +} + +type ClassroomGuardianInvitesCmd struct { + List ClassroomGuardianInvitesListCmd `cmd:"" default:"withargs" help:"List guardian invitations"` + Get ClassroomGuardianInvitesGetCmd `cmd:"" help:"Get a guardian invitation"` + Create ClassroomGuardianInvitesCreateCmd `cmd:"" help:"Create a guardian invitation"` +} + +type ClassroomGuardianInvitesListCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + Email string `name:"email" help:"Filter by invited email address"` + States string `name:"state" help:"Invitation states filter (comma-separated: PENDING,COMPLETE)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomGuardianInvitesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + if studentID == "" { + return usage("empty studentId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.UserProfiles.GuardianInvitations.List(studentID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if v := strings.TrimSpace(c.Email); v != "" { + call.InvitedEmailAddress(v) + } + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.States(upper...) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "invitations": resp.GuardianInvitations, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.GuardianInvitations) == 0 { + u.Err().Println("No guardian invitations") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "INVITATION_ID\tEMAIL\tSTATE\tCREATED") + for _, inv := range resp.GuardianInvitations { + if inv == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(inv.InvitationId), + sanitizeTab(inv.InvitedEmailAddress), + sanitizeTab(inv.State), + sanitizeTab(inv.CreationTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomGuardianInvitesGetCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + InvitationID string `arg:"" name:"invitationId" help:"Invitation ID"` +} + +func (c *ClassroomGuardianInvitesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + invitationID := strings.TrimSpace(c.InvitationID) + if studentID == "" { + return usage("empty studentId") + } + if invitationID == "" { + return usage("empty invitationId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + inv, err := svc.UserProfiles.GuardianInvitations.Get(studentID, invitationID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"invitation": inv}) + } + + u.Out().Printf("id\t%s", inv.InvitationId) + u.Out().Printf("student_id\t%s", inv.StudentId) + u.Out().Printf("email\t%s", inv.InvitedEmailAddress) + u.Out().Printf("state\t%s", inv.State) + if inv.CreationTime != "" { + u.Out().Printf("created\t%s", inv.CreationTime) + } + return nil +} + +type ClassroomGuardianInvitesCreateCmd struct { + StudentID string `arg:"" name:"studentId" help:"Student ID"` + Email string `name:"email" help:"Guardian email address" required:""` +} + +func (c *ClassroomGuardianInvitesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + studentID := strings.TrimSpace(c.StudentID) + if studentID == "" { + return usage("empty studentId") + } + email := strings.TrimSpace(c.Email) + if email == "" { + return usage("empty email") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + invite := &classroom.GuardianInvitation{InvitedEmailAddress: email} + created, err := svc.UserProfiles.GuardianInvitations.Create(studentID, invite).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"invitation": created}) + } + u.Out().Printf("id\t%s", created.InvitationId) + u.Out().Printf("student_id\t%s", created.StudentId) + u.Out().Printf("email\t%s", created.InvitedEmailAddress) + return nil +} diff --git a/internal/cmd/classroom_helpers.go b/internal/cmd/classroom_helpers.go new file mode 100644 index 000000000..9b05b7788 --- /dev/null +++ b/internal/cmd/classroom_helpers.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + "time" + + "google.golang.org/api/classroom/v1" +) + +func wrapClassroomError(err error) error { + if err == nil { + return nil + } + errStr := err.Error() + if strings.Contains(errStr, "accessNotConfigured") || + strings.Contains(errStr, "Classroom API has not been used") { + return fmt.Errorf("classroom API is not enabled; enable it at: https://console.developers.google.com/apis/api/classroom.googleapis.com/overview (%w)", err) + } + if strings.Contains(errStr, "insufficientPermissions") || + strings.Contains(errStr, "insufficient authentication scopes") { + return fmt.Errorf("insufficient permissions for Classroom API; re-authenticate with: gog auth add --services classroom\n\nOriginal error: %w", err) + } + return err +} + +func formatClassroomDate(d *classroom.Date) string { + if d == nil || d.Year == 0 || d.Month == 0 || d.Day == 0 { + return "" + } + return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day) +} + +func formatClassroomTime(t *classroom.TimeOfDay) string { + if t == nil { + return "" + } + if t.Seconds != 0 || t.Nanos != 0 { + return fmt.Sprintf("%02d:%02d:%02d", t.Hours, t.Minutes, t.Seconds) + } + return fmt.Sprintf("%02d:%02d", t.Hours, t.Minutes) +} + +func formatClassroomDue(d *classroom.Date, t *classroom.TimeOfDay) string { + date := formatClassroomDate(d) + clock := formatClassroomTime(t) + if date == "" && clock == "" { + return "" + } + if clock == "" { + return date + } + if date == "" { + return clock + } + return fmt.Sprintf("%s %s", date, clock) +} + +func parseClassroomDate(value string) (*classroom.Date, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("empty date") + } + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + return nil, fmt.Errorf("invalid date %q (expected YYYY-MM-DD)", value) + } + return &classroom.Date{Year: int64(parsed.Year()), Month: int64(parsed.Month()), Day: int64(parsed.Day())}, nil +} + +func parseClassroomTime(value string) (*classroom.TimeOfDay, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("empty time") + } + parsed, err := time.Parse("15:04", value) + if err != nil { + parsed, err = time.Parse("15:04:05", value) + if err != nil { + return nil, fmt.Errorf("invalid time %q (expected HH:MM or HH:MM:SS)", value) + } + } + return &classroom.TimeOfDay{ + Hours: int64(parsed.Hour()), + Minutes: int64(parsed.Minute()), + Seconds: int64(parsed.Second()), + }, nil +} + +func parseClassroomDue(value string) (*classroom.Date, *classroom.TimeOfDay, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil, nil + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + utc := t.UTC() + return &classroom.Date{Year: int64(utc.Year()), Month: int64(utc.Month()), Day: int64(utc.Day())}, &classroom.TimeOfDay{Hours: int64(utc.Hour()), Minutes: int64(utc.Minute()), Seconds: int64(utc.Second())}, nil + } + if t, err := time.Parse("2006-01-02 15:04", value); err == nil { + utc := t.UTC() + return &classroom.Date{Year: int64(utc.Year()), Month: int64(utc.Month()), Day: int64(utc.Day())}, &classroom.TimeOfDay{Hours: int64(utc.Hour()), Minutes: int64(utc.Minute()), Seconds: int64(utc.Second())}, nil + } + if t, err := time.Parse("2006-01-02T15:04", value); err == nil { + utc := t.UTC() + return &classroom.Date{Year: int64(utc.Year()), Month: int64(utc.Month()), Day: int64(utc.Day())}, &classroom.TimeOfDay{Hours: int64(utc.Hour()), Minutes: int64(utc.Minute()), Seconds: int64(utc.Second())}, nil + } + if d, err := parseClassroomDate(value); err == nil { + return d, nil, nil + } + return nil, nil, fmt.Errorf("invalid due value %q (expected RFC3339 or YYYY-MM-DD [HH:MM])", value) +} + +func updateMask(fields []string) string { + if len(fields) == 0 { + return "" + } + return strings.Join(fields, ",") +} + +func normalizeAssigneeMode(mode string, addStudents, removeStudents []string) (string, *classroom.ModifyIndividualStudentsOptions, error) { + mode = strings.TrimSpace(mode) + hasChanges := len(addStudents) > 0 || len(removeStudents) > 0 + if hasChanges { + if mode == "" { + mode = "INDIVIDUAL_STUDENTS" + } + mode = strings.ToUpper(mode) + if mode != "INDIVIDUAL_STUDENTS" { + return "", nil, fmt.Errorf("assignee mode must be INDIVIDUAL_STUDENTS when modifying individual students") + } + return mode, &classroom.ModifyIndividualStudentsOptions{ + AddStudentIds: addStudents, + RemoveStudentIds: removeStudents, + }, nil + } + if mode == "" { + return "", nil, nil + } + return strings.ToUpper(mode), nil, nil +} + +func parseFloat(value string) (float64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("empty value") + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("invalid number %q", value) + } + return parsed, nil +} + +func profileName(profile *classroom.UserProfile) string { + if profile == nil || profile.Name == nil { + return "" + } + if profile.Name.FullName != "" { + return profile.Name.FullName + } + return strings.TrimSpace(strings.TrimSpace(profile.Name.GivenName + " " + profile.Name.FamilyName)) +} + +func profileEmail(profile *classroom.UserProfile) string { + if profile == nil { + return "" + } + return profile.EmailAddress +} + +func formatFloatValue(v float64) string { + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", v), "0"), ".") +} diff --git a/internal/cmd/classroom_helpers_test.go b/internal/cmd/classroom_helpers_test.go new file mode 100644 index 000000000..840f4aff7 --- /dev/null +++ b/internal/cmd/classroom_helpers_test.go @@ -0,0 +1,693 @@ +package cmd + +import ( + "errors" + "strings" + "testing" + + "google.golang.org/api/classroom/v1" +) + +func TestWrapClassroomError(t *testing.T) { + tests := []struct { + name string + err error + wantNil bool + contains string + }{ + { + name: "nil error returns nil", + err: nil, + wantNil: true, + }, + { + name: "accessNotConfigured wraps with enable link", + err: errors.New("accessNotConfigured: Classroom API has not been used"), + contains: "console.developers.google.com", + }, + { + name: "Classroom API has not been used wraps with enable link", + err: errors.New("Classroom API has not been used in project"), + contains: "classroom.googleapis.com", + }, + { + name: "insufficientPermissions wraps with re-auth hint", + err: errors.New("insufficientPermissions: Request had insufficient auth"), + contains: "gog auth add", + }, + { + name: "insufficient authentication scopes wraps with re-auth hint", + err: errors.New("insufficient authentication scopes"), + contains: "--services classroom", + }, + { + name: "other errors pass through", + err: errors.New("some other error"), + contains: "some other error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapClassroomError(tt.err) + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil error") + } + if tt.contains != "" && !strings.Contains(got.Error(), tt.contains) { + t.Errorf("error %q does not contain %q", got.Error(), tt.contains) + } + }) + } +} + +func TestFormatClassroomDate(t *testing.T) { + tests := []struct { + name string + date *classroom.Date + want string + }{ + { + name: "nil date returns empty", + date: nil, + want: "", + }, + { + name: "zero values return empty", + date: &classroom.Date{Year: 0, Month: 0, Day: 0}, + want: "", + }, + { + name: "partial values return empty", + date: &classroom.Date{Year: 2024, Month: 0, Day: 15}, + want: "", + }, + { + name: "valid date formats correctly", + date: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + want: "2024-03-15", + }, + { + name: "single digit month and day get padded", + date: &classroom.Date{Year: 2024, Month: 1, Day: 5}, + want: "2024-01-05", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatClassroomDate(tt.date) + if got != tt.want { + t.Errorf("formatClassroomDate() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatClassroomTime(t *testing.T) { + tests := []struct { + name string + time *classroom.TimeOfDay + want string + }{ + { + name: "nil time returns empty", + time: nil, + want: "", + }, + { + name: "hours and minutes only", + time: &classroom.TimeOfDay{Hours: 14, Minutes: 30}, + want: "14:30", + }, + { + name: "with seconds", + time: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 45}, + want: "14:30:45", + }, + { + name: "with nanos (shows seconds)", + time: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Nanos: 1}, + want: "14:30:00", + }, + { + name: "single digit hours and minutes get padded", + time: &classroom.TimeOfDay{Hours: 9, Minutes: 5}, + want: "09:05", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatClassroomTime(tt.time) + if got != tt.want { + t.Errorf("formatClassroomTime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatClassroomDue(t *testing.T) { + tests := []struct { + name string + date *classroom.Date + time *classroom.TimeOfDay + want string + }{ + { + name: "nil date and time returns empty", + date: nil, + time: nil, + want: "", + }, + { + name: "date only", + date: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + time: nil, + want: "2024-03-15", + }, + { + name: "time only", + date: nil, + time: &classroom.TimeOfDay{Hours: 14, Minutes: 30}, + want: "14:30", + }, + { + name: "date and time", + date: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + time: &classroom.TimeOfDay{Hours: 14, Minutes: 30}, + want: "2024-03-15 14:30", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatClassroomDue(tt.date, tt.time) + if got != tt.want { + t.Errorf("formatClassroomDue() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseClassroomDate(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + want *classroom.Date + }{ + { + name: "empty value errors", + value: "", + wantErr: true, + }, + { + name: "whitespace only errors", + value: " ", + wantErr: true, + }, + { + name: "invalid format errors", + value: "2024/03/15", + wantErr: true, + }, + { + name: "valid date parses correctly", + value: "2024-03-15", + wantErr: false, + want: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + }, + { + name: "whitespace trimmed", + value: " 2024-03-15 ", + wantErr: false, + want: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseClassroomDate(tt.value) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Year != tt.want.Year || got.Month != tt.want.Month || got.Day != tt.want.Day { + t.Errorf("parseClassroomDate() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParseClassroomTime(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + want *classroom.TimeOfDay + }{ + { + name: "empty value errors", + value: "", + wantErr: true, + }, + { + name: "invalid format errors", + value: "14:30:45:00", + wantErr: true, + }, + { + name: "HH:MM format parses", + value: "14:30", + wantErr: false, + want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0}, + }, + { + name: "HH:MM:SS format parses", + value: "14:30:45", + wantErr: false, + want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 45}, + }, + { + name: "whitespace trimmed", + value: " 14:30 ", + wantErr: false, + want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseClassroomTime(tt.value) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Hours != tt.want.Hours || got.Minutes != tt.want.Minutes || got.Seconds != tt.want.Seconds { + t.Errorf("parseClassroomTime() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParseClassroomDue(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + wantDate *classroom.Date + wantTime *classroom.TimeOfDay + }{ + { + name: "empty value returns nil", + value: "", + wantErr: false, + wantDate: nil, + wantTime: nil, + }, + { + name: "RFC3339 format parses", + value: "2024-03-15T14:30:00Z", + wantErr: false, + wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0}, + }, + { + name: "YYYY-MM-DD HH:MM format parses", + value: "2024-03-15 14:30", + wantErr: false, + wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0}, + }, + { + name: "YYYY-MM-DDTHH:MM format parses", + value: "2024-03-15T14:30", + wantErr: false, + wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0}, + }, + { + name: "date only parses", + value: "2024-03-15", + wantErr: false, + wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15}, + wantTime: nil, + }, + { + name: "invalid format errors", + value: "not-a-date", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDate, gotTime, err := parseClassroomDue(tt.value) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantDate == nil { + if gotDate != nil { + t.Errorf("expected nil date, got %+v", gotDate) + } + } else { + if gotDate == nil { + t.Fatal("expected non-nil date") + } + if gotDate.Year != tt.wantDate.Year || gotDate.Month != tt.wantDate.Month || gotDate.Day != tt.wantDate.Day { + t.Errorf("date = %+v, want %+v", gotDate, tt.wantDate) + } + } + + if tt.wantTime == nil { + if gotTime != nil { + t.Errorf("expected nil time, got %+v", gotTime) + } + } else { + if gotTime == nil { + t.Fatal("expected non-nil time") + } + if gotTime.Hours != tt.wantTime.Hours || gotTime.Minutes != tt.wantTime.Minutes { + t.Errorf("time = %+v, want %+v", gotTime, tt.wantTime) + } + } + }) + } +} + +func TestUpdateMask(t *testing.T) { + tests := []struct { + name string + fields []string + want string + }{ + { + name: "empty slice returns empty", + fields: []string{}, + want: "", + }, + { + name: "single field", + fields: []string{"name"}, + want: "name", + }, + { + name: "multiple fields joined with comma", + fields: []string{"name", "description", "state"}, + want: "name,description,state", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateMask(tt.fields) + if got != tt.want { + t.Errorf("updateMask() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNormalizeAssigneeMode(t *testing.T) { + tests := []struct { + name string + mode string + add []string + remove []string + wantMode string + wantOpts bool + wantAdd []string + wantRemove []string + wantErrSubstr string + }{ + { + name: "no mode or students returns empty", + wantMode: "", + wantOpts: false, + }, + { + name: "mode only uppercases", + mode: "all_students", + wantMode: "ALL_STUDENTS", + wantOpts: false, + }, + { + name: "students default mode", + add: []string{"a", "b"}, + remove: []string{"c"}, + wantMode: "INDIVIDUAL_STUDENTS", + wantOpts: true, + wantAdd: []string{"a", "b"}, + wantRemove: []string{"c"}, + }, + { + name: "students with explicit mode", + mode: "INDIVIDUAL_STUDENTS", + add: []string{"a"}, + wantMode: "INDIVIDUAL_STUDENTS", + wantOpts: true, + wantAdd: []string{"a"}, + }, + { + name: "students with invalid mode errors", + mode: "ALL_STUDENTS", + add: []string{"a"}, + wantErrSubstr: "INDIVIDUAL_STUDENTS", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMode, gotOpts, err := normalizeAssigneeMode(tt.mode, tt.add, tt.remove) + if tt.wantErrSubstr != "" { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErrSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMode != tt.wantMode { + t.Errorf("mode = %q, want %q", gotMode, tt.wantMode) + } + if (gotOpts != nil) != tt.wantOpts { + t.Fatalf("opts nil = %v, want %v", gotOpts == nil, !tt.wantOpts) + } + if tt.wantOpts { + if strings.Join(gotOpts.AddStudentIds, ",") != strings.Join(tt.wantAdd, ",") { + t.Errorf("add = %v, want %v", gotOpts.AddStudentIds, tt.wantAdd) + } + if strings.Join(gotOpts.RemoveStudentIds, ",") != strings.Join(tt.wantRemove, ",") { + t.Errorf("remove = %v, want %v", gotOpts.RemoveStudentIds, tt.wantRemove) + } + } + }) + } +} + +func TestParseFloat(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + want float64 + }{ + { + name: "empty value errors", + value: "", + wantErr: true, + }, + { + name: "invalid format errors", + value: "not-a-number", + wantErr: true, + }, + { + name: "integer parses", + value: "42", + wantErr: false, + want: 42.0, + }, + { + name: "decimal parses", + value: "3.14", + wantErr: false, + want: 3.14, + }, + { + name: "whitespace trimmed", + value: " 3.14 ", + wantErr: false, + want: 3.14, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFloat(tt.value) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("parseFloat() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProfileName(t *testing.T) { + tests := []struct { + name string + profile *classroom.UserProfile + want string + }{ + { + name: "nil profile returns empty", + profile: nil, + want: "", + }, + { + name: "nil name returns empty", + profile: &classroom.UserProfile{Name: nil}, + want: "", + }, + { + name: "full name preferred", + profile: &classroom.UserProfile{Name: &classroom.Name{FullName: "John Doe", GivenName: "John", FamilyName: "Doe"}}, + want: "John Doe", + }, + { + name: "falls back to given + family name", + profile: &classroom.UserProfile{Name: &classroom.Name{GivenName: "John", FamilyName: "Doe"}}, + want: "John Doe", + }, + { + name: "handles missing family name", + profile: &classroom.UserProfile{Name: &classroom.Name{GivenName: "John"}}, + want: "John", + }, + { + name: "handles missing given name", + profile: &classroom.UserProfile{Name: &classroom.Name{FamilyName: "Doe"}}, + want: "Doe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := profileName(tt.profile) + if got != tt.want { + t.Errorf("profileName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestProfileEmail(t *testing.T) { + tests := []struct { + name string + profile *classroom.UserProfile + want string + }{ + { + name: "nil profile returns empty", + profile: nil, + want: "", + }, + { + name: "returns email address", + profile: &classroom.UserProfile{EmailAddress: "test@example.com"}, + want: "test@example.com", + }, + { + name: "empty email returns empty", + profile: &classroom.UserProfile{EmailAddress: ""}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := profileEmail(tt.profile) + if got != tt.want { + t.Errorf("profileEmail() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatFloatValue(t *testing.T) { + tests := []struct { + name string + value float64 + want string + }{ + { + name: "integer value", + value: 100.0, + want: "100", + }, + { + name: "single decimal place", + value: 85.5, + want: "85.5", + }, + { + name: "two decimal places", + value: 85.75, + want: "85.75", + }, + { + name: "trailing zeros removed", + value: 85.10, + want: "85.1", + }, + { + name: "zero", + value: 0.0, + want: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatFloatValue(tt.value) + if got != tt.want { + t.Errorf("formatFloatValue() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/classroom_invitations.go b/internal/cmd/classroom_invitations.go new file mode 100644 index 000000000..596adb026 --- /dev/null +++ b/internal/cmd/classroom_invitations.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomInvitationsCmd struct { + List ClassroomInvitationsListCmd `cmd:"" default:"withargs" help:"List invitations"` + Get ClassroomInvitationsGetCmd `cmd:"" help:"Get an invitation"` + Create ClassroomInvitationsCreateCmd `cmd:"" help:"Create an invitation"` + Accept ClassroomInvitationsAcceptCmd `cmd:"" help:"Accept an invitation"` + Delete ClassroomInvitationsDeleteCmd `cmd:"" help:"Delete an invitation" aliases:"rm"` +} + +type ClassroomInvitationsListCmd struct { + CourseID string `name:"course" help:"Filter by course ID"` + UserID string `name:"user" help:"Filter by user ID or email"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomInvitationsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Invitations.List().PageSize(c.Max).PageToken(c.Page).Context(ctx) + if v := strings.TrimSpace(c.CourseID); v != "" { + call.CourseId(v) + } + if v := strings.TrimSpace(c.UserID); v != "" { + call.UserId(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "invitations": resp.Invitations, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Invitations) == 0 { + u.Err().Println("No invitations") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tCOURSE_ID\tUSER_ID\tROLE") + for _, inv := range resp.Invitations { + if inv == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(inv.Id), + sanitizeTab(inv.CourseId), + sanitizeTab(inv.UserId), + sanitizeTab(inv.Role), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomInvitationsGetCmd struct { + InvitationID string `arg:"" name:"invitationId" help:"Invitation ID"` +} + +func (c *ClassroomInvitationsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + invitationID := strings.TrimSpace(c.InvitationID) + if invitationID == "" { + return usage("empty invitationId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + inv, err := svc.Invitations.Get(invitationID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"invitation": inv}) + } + + u.Out().Printf("id\t%s", inv.Id) + u.Out().Printf("course_id\t%s", inv.CourseId) + u.Out().Printf("user_id\t%s", inv.UserId) + u.Out().Printf("role\t%s", inv.Role) + return nil +} + +type ClassroomInvitationsCreateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"User ID or email"` + Role string `name:"role" help:"Role: STUDENT, TEACHER, OWNER" required:""` +} + +func (c *ClassroomInvitationsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + role := strings.TrimSpace(c.Role) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + if role == "" { + return usage("empty role") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + inv := &classroom.Invitation{CourseId: courseID, UserId: userID, Role: strings.ToUpper(role)} + created, err := svc.Invitations.Create(inv).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"invitation": created}) + } + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("course_id\t%s", created.CourseId) + u.Out().Printf("user_id\t%s", created.UserId) + u.Out().Printf("role\t%s", created.Role) + return nil +} + +type ClassroomInvitationsAcceptCmd struct { + InvitationID string `arg:"" name:"invitationId" help:"Invitation ID"` +} + +func (c *ClassroomInvitationsAcceptCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + invitationID := strings.TrimSpace(c.InvitationID) + if invitationID == "" { + return usage("empty invitationId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Invitations.Accept(invitationID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "accepted": true, + "invitationId": invitationID, + }) + } + u.Out().Printf("accepted\ttrue") + u.Out().Printf("invitation_id\t%s", invitationID) + return nil +} + +type ClassroomInvitationsDeleteCmd struct { + InvitationID string `arg:"" name:"invitationId" help:"Invitation ID"` +} + +func (c *ClassroomInvitationsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + invitationID := strings.TrimSpace(c.InvitationID) + if invitationID == "" { + return usage("empty invitationId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete invitation %s", invitationID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Invitations.Delete(invitationID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "invitationId": invitationID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("invitation_id\t%s", invitationID) + return nil +} diff --git a/internal/cmd/classroom_materials.go b/internal/cmd/classroom_materials.go new file mode 100644 index 000000000..38765118b --- /dev/null +++ b/internal/cmd/classroom_materials.go @@ -0,0 +1,328 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomMaterialsCmd struct { + List ClassroomMaterialsListCmd `cmd:"" default:"withargs" help:"List coursework materials"` + Get ClassroomMaterialsGetCmd `cmd:"" help:"Get coursework material"` + Create ClassroomMaterialsCreateCmd `cmd:"" help:"Create coursework material"` + Update ClassroomMaterialsUpdateCmd `cmd:"" help:"Update coursework material"` + Delete ClassroomMaterialsDeleteCmd `cmd:"" help:"Delete coursework material" aliases:"rm"` +} + +type ClassroomMaterialsListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + States string `name:"state" help:"Material states filter (comma-separated: PUBLISHED,DRAFT,DELETED)"` + Topic string `name:"topic" help:"Filter by topic ID"` + OrderBy string `name:"order-by" help:"Order by (e.g., updateTime desc)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Courses.CourseWorkMaterials.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.CourseWorkMaterialStates(upper...) + } + if v := strings.TrimSpace(c.OrderBy); v != "" { + call.OrderBy(v) + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + // Client-side filter by topic (API doesn't support server-side topic filter) + topicFilter := strings.TrimSpace(c.Topic) + materials := resp.CourseWorkMaterial + if topicFilter != "" { + filtered := make([]*classroom.CourseWorkMaterial, 0, len(materials)) + for _, material := range materials { + if material != nil && material.TopicId == topicFilter { + filtered = append(filtered, material) + } + } + materials = filtered + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "materials": materials, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(materials) == 0 { + u.Err().Println("No materials") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTITLE\tSTATE\tUPDATED") + for _, material := range materials { + if material == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(material.Id), + sanitizeTab(material.Title), + sanitizeTab(material.State), + sanitizeTab(material.UpdateTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomMaterialsGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + MaterialID string `arg:"" name:"materialId" help:"Material ID"` +} + +func (c *ClassroomMaterialsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + materialID := strings.TrimSpace(c.MaterialID) + if courseID == "" { + return usage("empty courseId") + } + if materialID == "" { + return usage("empty materialId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + material, err := svc.Courses.CourseWorkMaterials.Get(courseID, materialID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"material": material}) + } + + u.Out().Printf("id\t%s", material.Id) + u.Out().Printf("title\t%s", material.Title) + if material.Description != "" { + u.Out().Printf("description\t%s", material.Description) + } + u.Out().Printf("state\t%s", material.State) + if material.TopicId != "" { + u.Out().Printf("topic_id\t%s", material.TopicId) + } + if material.ScheduledTime != "" { + u.Out().Printf("scheduled\t%s", material.ScheduledTime) + } + return nil +} + +type ClassroomMaterialsCreateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Title string `name:"title" help:"Title" required:""` + Description string `name:"description" help:"Description"` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` + TopicID string `name:"topic" help:"Topic ID"` +} + +func (c *ClassroomMaterialsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + if strings.TrimSpace(c.Title) == "" { + return usage("empty title") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + material := &classroom.CourseWorkMaterial{Title: strings.TrimSpace(c.Title)} + if v := strings.TrimSpace(c.Description); v != "" { + material.Description = v + } + if v := strings.TrimSpace(c.State); v != "" { + material.State = strings.ToUpper(v) + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + material.ScheduledTime = v + } + if v := strings.TrimSpace(c.TopicID); v != "" { + material.TopicId = v + } + + created, err := svc.Courses.CourseWorkMaterials.Create(courseID, material).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"material": created}) + } + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("title\t%s", created.Title) + u.Out().Printf("state\t%s", created.State) + return nil +} + +type ClassroomMaterialsUpdateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + MaterialID string `arg:"" name:"materialId" help:"Material ID"` + Title string `name:"title" help:"Title"` + Description string `name:"description" help:"Description"` + State string `name:"state" help:"State: PUBLISHED, DRAFT"` + Scheduled string `name:"scheduled" help:"Scheduled publish time (RFC3339)"` + TopicID string `name:"topic" help:"Topic ID"` +} + +func (c *ClassroomMaterialsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + materialID := strings.TrimSpace(c.MaterialID) + if courseID == "" { + return usage("empty courseId") + } + if materialID == "" { + return usage("empty materialId") + } + + material := &classroom.CourseWorkMaterial{} + fields := make([]string, 0, 4) + if v := strings.TrimSpace(c.Title); v != "" { + material.Title = v + fields = append(fields, "title") + } + if v := strings.TrimSpace(c.Description); v != "" { + material.Description = v + fields = append(fields, "description") + } + if v := strings.TrimSpace(c.State); v != "" { + material.State = strings.ToUpper(v) + fields = append(fields, "state") + } + if v := strings.TrimSpace(c.Scheduled); v != "" { + material.ScheduledTime = v + fields = append(fields, "scheduledTime") + } + if v := strings.TrimSpace(c.TopicID); v != "" { + material.TopicId = v + fields = append(fields, "topicId") + } + if len(fields) == 0 { + return usage("no updates specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.CourseWorkMaterials.Patch(courseID, materialID, material).UpdateMask(updateMask(fields)).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"material": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("title\t%s", updated.Title) + u.Out().Printf("state\t%s", updated.State) + return nil +} + +type ClassroomMaterialsDeleteCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + MaterialID string `arg:"" name:"materialId" help:"Material ID"` +} + +func (c *ClassroomMaterialsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + materialID := strings.TrimSpace(c.MaterialID) + if courseID == "" { + return usage("empty courseId") + } + if materialID == "" { + return usage("empty materialId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete material %s from %s", materialID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.CourseWorkMaterials.Delete(courseID, materialID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "courseId": courseID, + "materialId": materialID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("material_id\t%s", materialID) + return nil +} diff --git a/internal/cmd/classroom_profile.go b/internal/cmd/classroom_profile.go new file mode 100644 index 000000000..9bf4c19b8 --- /dev/null +++ b/internal/cmd/classroom_profile.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "context" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomProfileCmd struct { + Get ClassroomProfileGetCmd `cmd:"" default:"withargs" help:"Get a user profile"` +} + +type ClassroomProfileGetCmd struct { + UserID string `arg:"" name:"userId" optional:"" help:"User ID or email (default: me)"` +} + +func (c *ClassroomProfileGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + userID := strings.TrimSpace(c.UserID) + if userID == "" { + userID = "me" + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + profile, err := svc.UserProfiles.Get(userID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"profile": profile}) + } + + u.Out().Printf("id\t%s", profile.Id) + u.Out().Printf("email\t%s", profile.EmailAddress) + u.Out().Printf("name\t%s", profileName(profile)) + u.Out().Printf("verified_teacher\t%t", profile.VerifiedTeacher) + if profile.PhotoUrl != "" { + u.Out().Printf("photo_url\t%s", profile.PhotoUrl) + } + return nil +} diff --git a/internal/cmd/classroom_rosters.go b/internal/cmd/classroom_rosters.go new file mode 100644 index 000000000..3415995c4 --- /dev/null +++ b/internal/cmd/classroom_rosters.go @@ -0,0 +1,493 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomStudentsCmd struct { + List ClassroomStudentsListCmd `cmd:"" default:"withargs" help:"List students"` + Get ClassroomStudentsGetCmd `cmd:"" help:"Get a student"` + Add ClassroomStudentsAddCmd `cmd:"" help:"Add a student"` + Remove ClassroomStudentsRemoveCmd `cmd:"" help:"Remove a student" aliases:"delete,rm"` +} + +type ClassroomStudentsListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomStudentsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + resp, err := svc.Courses.Students.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "students": resp.Students, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Students) == 0 { + u.Err().Println("No students") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "USER_ID\tEMAIL\tNAME") + for _, student := range resp.Students { + if student == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(student.UserId), + sanitizeTab(profileEmail(student.Profile)), + sanitizeTab(profileName(student.Profile)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomStudentsGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Student user ID or email"` +} + +func (c *ClassroomStudentsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + student, err := svc.Courses.Students.Get(courseID, userID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"student": student}) + } + + u.Out().Printf("user_id\t%s", student.UserId) + u.Out().Printf("email\t%s", profileEmail(student.Profile)) + u.Out().Printf("name\t%s", profileName(student.Profile)) + if student.StudentWorkFolder != nil { + u.Out().Printf("work_folder\t%s", student.StudentWorkFolder.Id) + } + return nil +} + +type ClassroomStudentsAddCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Student user ID or email"` + EnrollmentCode string `name:"enrollment-code" help:"Enrollment code"` +} + +func (c *ClassroomStudentsAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + student := &classroom.Student{UserId: userID} + call := svc.Courses.Students.Create(courseID, student).Context(ctx) + if code := strings.TrimSpace(c.EnrollmentCode); code != "" { + call.EnrollmentCode(code) + } + created, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"student": created}) + } + u.Out().Printf("user_id\t%s", created.UserId) + u.Out().Printf("email\t%s", profileEmail(created.Profile)) + u.Out().Printf("name\t%s", profileName(created.Profile)) + return nil +} + +type ClassroomStudentsRemoveCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Student user ID or email"` +} + +func (c *ClassroomStudentsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("remove student %s from %s", userID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.Students.Delete(courseID, userID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "removed": true, + "courseId": courseID, + "userId": userID, + }) + } + u.Out().Printf("removed\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("user_id\t%s", userID) + return nil +} + +type ClassroomTeachersCmd struct { + List ClassroomTeachersListCmd `cmd:"" default:"withargs" help:"List teachers"` + Get ClassroomTeachersGetCmd `cmd:"" help:"Get a teacher"` + Add ClassroomTeachersAddCmd `cmd:"" help:"Add a teacher"` + Remove ClassroomTeachersRemoveCmd `cmd:"" help:"Remove a teacher" aliases:"delete,rm"` +} + +type ClassroomTeachersListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomTeachersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + resp, err := svc.Courses.Teachers.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "teachers": resp.Teachers, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Teachers) == 0 { + u.Err().Println("No teachers") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "USER_ID\tEMAIL\tNAME") + for _, teacher := range resp.Teachers { + if teacher == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(teacher.UserId), + sanitizeTab(profileEmail(teacher.Profile)), + sanitizeTab(profileName(teacher.Profile)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomTeachersGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Teacher user ID or email"` +} + +func (c *ClassroomTeachersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + teacher, err := svc.Courses.Teachers.Get(courseID, userID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"teacher": teacher}) + } + + u.Out().Printf("user_id\t%s", teacher.UserId) + u.Out().Printf("email\t%s", profileEmail(teacher.Profile)) + u.Out().Printf("name\t%s", profileName(teacher.Profile)) + return nil +} + +type ClassroomTeachersAddCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Teacher user ID or email"` +} + +func (c *ClassroomTeachersAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + teacher := &classroom.Teacher{UserId: userID} + created, err := svc.Courses.Teachers.Create(courseID, teacher).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"teacher": created}) + } + u.Out().Printf("user_id\t%s", created.UserId) + u.Out().Printf("email\t%s", profileEmail(created.Profile)) + u.Out().Printf("name\t%s", profileName(created.Profile)) + return nil +} + +type ClassroomTeachersRemoveCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + UserID string `arg:"" name:"userId" help:"Teacher user ID or email"` +} + +func (c *ClassroomTeachersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + userID := strings.TrimSpace(c.UserID) + if courseID == "" { + return usage("empty courseId") + } + if userID == "" { + return usage("empty userId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("remove teacher %s from %s", userID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.Teachers.Delete(courseID, userID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "removed": true, + "courseId": courseID, + "userId": userID, + }) + } + u.Out().Printf("removed\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("user_id\t%s", userID) + return nil +} + +type ClassroomRosterCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Students bool `name:"students" help:"Include students"` + Teachers bool `name:"teachers" help:"Include teachers"` + Max int64 `name:"max" aliases:"limit" help:"Max results (per role)" default:"100"` + Page string `name:"page" help:"Page token (per role)"` +} + +func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + includeStudents := c.Students || (!c.Students && !c.Teachers) + includeTeachers := c.Teachers || (!c.Students && !c.Teachers) + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + var studentsResp *classroom.ListStudentsResponse + var teachersResp *classroom.ListTeachersResponse + + if includeStudents { + studentsResp, err = svc.Courses.Students.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + } + if includeTeachers { + teachersResp, err = svc.Courses.Teachers.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{"courseId": courseID} + if includeStudents { + payload["students"] = studentsResp.Students + payload["studentsNextPageToken"] = studentsResp.NextPageToken + } + if includeTeachers { + payload["teachers"] = teachersResp.Teachers + payload["teachersNextPageToken"] = teachersResp.NextPageToken + } + return outfmt.WriteJSON(os.Stdout, payload) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ROLE\tUSER_ID\tEMAIL\tNAME") + if includeTeachers { + for _, teacher := range teachersResp.Teachers { + if teacher == nil { + continue + } + fmt.Fprintf(w, "teacher\t%s\t%s\t%s\n", + sanitizeTab(teacher.UserId), + sanitizeTab(profileEmail(teacher.Profile)), + sanitizeTab(profileName(teacher.Profile)), + ) + } + if teachersResp.NextPageToken != "" { + u.Err().Printf("# Next teachers page: --page %s", teachersResp.NextPageToken) + } + } + if includeStudents { + for _, student := range studentsResp.Students { + if student == nil { + continue + } + fmt.Fprintf(w, "student\t%s\t%s\t%s\n", + sanitizeTab(student.UserId), + sanitizeTab(profileEmail(student.Profile)), + sanitizeTab(profileName(student.Profile)), + ) + } + if studentsResp.NextPageToken != "" { + u.Err().Printf("# Next students page: --page %s", studentsResp.NextPageToken) + } + } + return nil +} diff --git a/internal/cmd/classroom_submissions.go b/internal/cmd/classroom_submissions.go new file mode 100644 index 000000000..8a191f87e --- /dev/null +++ b/internal/cmd/classroom_submissions.go @@ -0,0 +1,322 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomSubmissionsCmd struct { + List ClassroomSubmissionsListCmd `cmd:"" default:"withargs" help:"List student submissions"` + Get ClassroomSubmissionsGetCmd `cmd:"" help:"Get a student submission"` + TurnIn ClassroomSubmissionsTurnInCmd `cmd:"" name:"turn-in" help:"Turn in a submission"` + Reclaim ClassroomSubmissionsReclaimCmd `cmd:"" help:"Reclaim a submission"` + Return ClassroomSubmissionsReturnCmd `cmd:"" help:"Return a submission"` + Grade ClassroomSubmissionsGradeCmd `cmd:"" help:"Set draft/assigned grades"` +} + +type ClassroomSubmissionsListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + States string `name:"state" help:"Submission states filter (comma-separated: NEW,CREATED,TURNED_IN,RETURNED,RECLAIMED_BY_STUDENT)"` + Late string `name:"late" help:"Late filter: late|not-late"` + UserID string `name:"user" help:"Filter by user ID or email"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomSubmissionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + call := svc.Courses.CourseWork.StudentSubmissions.List(courseID, courseworkID).PageSize(c.Max).PageToken(c.Page).Context(ctx) + if states := splitCSV(c.States); len(states) > 0 { + upper := make([]string, 0, len(states)) + for _, state := range states { + upper = append(upper, strings.ToUpper(state)) + } + call.States(upper...) + } + if v := strings.TrimSpace(c.UserID); v != "" { + call.UserId(v) + } + if v := strings.ToLower(strings.TrimSpace(c.Late)); v != "" { + switch v { + case "late", "late_only", "late-only": + call.Late("LATE_ONLY") + case "not-late", "not_late", "not_late_only", "not-late-only", "not-late_only": + call.Late("NOT_LATE_ONLY") + default: + call.Late(strings.ToUpper(v)) + } + } + + resp, err := call.Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "submissions": resp.StudentSubmissions, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.StudentSubmissions) == 0 { + u.Err().Println("No submissions") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tUSER_ID\tSTATE\tLATE\tDRAFT\tASSIGNED\tUPDATED") + for _, sub := range resp.StudentSubmissions { + if sub == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\t%s\t%s\n", + sanitizeTab(sub.Id), + sanitizeTab(sub.UserId), + sanitizeTab(sub.State), + sub.Late, + formatFloatValue(sub.DraftGrade), + formatFloatValue(sub.AssignedGrade), + sanitizeTab(sub.UpdateTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomSubmissionsGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + SubmissionID string `arg:"" name:"submissionId" help:"Submission ID"` +} + +func (c *ClassroomSubmissionsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + submissionID := strings.TrimSpace(c.SubmissionID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + if submissionID == "" { + return usage("empty submissionId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + sub, err := svc.Courses.CourseWork.StudentSubmissions.Get(courseID, courseworkID, submissionID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"submission": sub}) + } + + u.Out().Printf("id\t%s", sub.Id) + u.Out().Printf("user_id\t%s", sub.UserId) + u.Out().Printf("state\t%s", sub.State) + u.Out().Printf("late\t%t", sub.Late) + u.Out().Printf("draft_grade\t%s", formatFloatValue(sub.DraftGrade)) + u.Out().Printf("assigned_grade\t%s", formatFloatValue(sub.AssignedGrade)) + if sub.UpdateTime != "" { + u.Out().Printf("updated\t%s", sub.UpdateTime) + } + if sub.AlternateLink != "" { + u.Out().Printf("link\t%s", sub.AlternateLink) + } + return nil +} + +type ClassroomSubmissionsTurnInCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + SubmissionID string `arg:"" name:"submissionId" help:"Submission ID"` +} + +func (c *ClassroomSubmissionsTurnInCmd) Run(ctx context.Context, flags *RootFlags) error { + return submissionAction(ctx, flags, c.CourseID, c.CourseworkID, c.SubmissionID, "turn-in") +} + +type ClassroomSubmissionsReclaimCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + SubmissionID string `arg:"" name:"submissionId" help:"Submission ID"` +} + +func (c *ClassroomSubmissionsReclaimCmd) Run(ctx context.Context, flags *RootFlags) error { + return submissionAction(ctx, flags, c.CourseID, c.CourseworkID, c.SubmissionID, "reclaim") +} + +type ClassroomSubmissionsReturnCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + SubmissionID string `arg:"" name:"submissionId" help:"Submission ID"` +} + +func (c *ClassroomSubmissionsReturnCmd) Run(ctx context.Context, flags *RootFlags) error { + return submissionAction(ctx, flags, c.CourseID, c.CourseworkID, c.SubmissionID, "return") +} + +func submissionAction(ctx context.Context, flags *RootFlags, courseID, courseworkID, submissionID, action string) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID = strings.TrimSpace(courseID) + courseworkID = strings.TrimSpace(courseworkID) + submissionID = strings.TrimSpace(submissionID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + if submissionID == "" { + return usage("empty submissionId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + switch action { + case "turn-in": + if _, err := svc.Courses.CourseWork.StudentSubmissions.TurnIn(courseID, courseworkID, submissionID, &classroom.TurnInStudentSubmissionRequest{}).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + case "reclaim": + if _, err := svc.Courses.CourseWork.StudentSubmissions.Reclaim(courseID, courseworkID, submissionID, &classroom.ReclaimStudentSubmissionRequest{}).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + case "return": + if _, err := svc.Courses.CourseWork.StudentSubmissions.Return(courseID, courseworkID, submissionID, &classroom.ReturnStudentSubmissionRequest{}).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + default: + return usagef("unknown action %q", action) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "ok": true, + "courseId": courseID, + "courseworkId": courseworkID, + "submissionId": submissionID, + "action": action, + }) + } + u.Out().Printf("ok\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("coursework_id\t%s", courseworkID) + u.Out().Printf("submission_id\t%s", submissionID) + u.Out().Printf("action\t%s", action) + return nil +} + +type ClassroomSubmissionsGradeCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + CourseworkID string `arg:"" name:"courseworkId" help:"Coursework ID"` + SubmissionID string `arg:"" name:"submissionId" help:"Submission ID"` + Draft string `name:"draft" help:"Draft grade"` + Assigned string `name:"assigned" help:"Assigned grade"` +} + +func (c *ClassroomSubmissionsGradeCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + courseworkID := strings.TrimSpace(c.CourseworkID) + submissionID := strings.TrimSpace(c.SubmissionID) + if courseID == "" { + return usage("empty courseId") + } + if courseworkID == "" { + return usage("empty courseworkId") + } + if submissionID == "" { + return usage("empty submissionId") + } + + fields := make([]string, 0, 2) + sub := &classroom.StudentSubmission{} + if strings.TrimSpace(c.Draft) != "" { + grade, parseErr := parseFloat(c.Draft) + if parseErr != nil { + return usage(parseErr.Error()) + } + sub.DraftGrade = grade + fields = append(fields, "draftGrade") + } + if strings.TrimSpace(c.Assigned) != "" { + grade, parseErr := parseFloat(c.Assigned) + if parseErr != nil { + return usage(parseErr.Error()) + } + sub.AssignedGrade = grade + fields = append(fields, "assignedGrade") + } + if len(fields) == 0 { + return usage("no grades specified") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + updated, err := svc.Courses.CourseWork.StudentSubmissions.Patch(courseID, courseworkID, submissionID, sub).UpdateMask(updateMask(fields)).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"submission": updated}) + } + u.Out().Printf("id\t%s", updated.Id) + u.Out().Printf("draft_grade\t%s", formatFloatValue(updated.DraftGrade)) + u.Out().Printf("assigned_grade\t%s", formatFloatValue(updated.AssignedGrade)) + return nil +} diff --git a/internal/cmd/classroom_topics.go b/internal/cmd/classroom_topics.go new file mode 100644 index 000000000..d76a391e8 --- /dev/null +++ b/internal/cmd/classroom_topics.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ClassroomTopicsCmd struct { + List ClassroomTopicsListCmd `cmd:"" default:"withargs" help:"List topics"` + Get ClassroomTopicsGetCmd `cmd:"" help:"Get a topic"` + Create ClassroomTopicsCreateCmd `cmd:"" help:"Create a topic"` + Update ClassroomTopicsUpdateCmd `cmd:"" help:"Update a topic"` + Delete ClassroomTopicsDeleteCmd `cmd:"" help:"Delete a topic" aliases:"rm"` +} + +type ClassroomTopicsListCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` +} + +func (c *ClassroomTopicsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + resp, err := svc.Courses.Topics.List(courseID).PageSize(c.Max).PageToken(c.Page).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "topics": resp.Topic, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.Topic) == 0 { + u.Err().Println("No topics") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "TOPIC_ID\tNAME\tUPDATED") + for _, topic := range resp.Topic { + if topic == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(topic.TopicId), + sanitizeTab(topic.Name), + sanitizeTab(topic.UpdateTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ClassroomTopicsGetCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + TopicID string `arg:"" name:"topicId" help:"Topic ID"` +} + +func (c *ClassroomTopicsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + topicID := strings.TrimSpace(c.TopicID) + if courseID == "" { + return usage("empty courseId") + } + if topicID == "" { + return usage("empty topicId") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + topic, err := svc.Courses.Topics.Get(courseID, topicID).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"topic": topic}) + } + + u.Out().Printf("id\t%s", topic.TopicId) + u.Out().Printf("name\t%s", topic.Name) + if topic.UpdateTime != "" { + u.Out().Printf("updated\t%s", topic.UpdateTime) + } + return nil +} + +type ClassroomTopicsCreateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + Name string `name:"name" help:"Topic name" required:""` +} + +func (c *ClassroomTopicsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + if courseID == "" { + return usage("empty courseId") + } + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("empty name") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + topic := &classroom.Topic{Name: name} + created, err := svc.Courses.Topics.Create(courseID, topic).Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"topic": created}) + } + u.Out().Printf("id\t%s", created.TopicId) + u.Out().Printf("name\t%s", created.Name) + return nil +} + +type ClassroomTopicsUpdateCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + TopicID string `arg:"" name:"topicId" help:"Topic ID"` + Name string `name:"name" help:"Topic name" required:""` +} + +func (c *ClassroomTopicsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + topicID := strings.TrimSpace(c.TopicID) + name := strings.TrimSpace(c.Name) + if courseID == "" { + return usage("empty courseId") + } + if topicID == "" { + return usage("empty topicId") + } + if name == "" { + return usage("empty name") + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + topic := &classroom.Topic{Name: name} + updated, err := svc.Courses.Topics.Patch(courseID, topicID, topic).UpdateMask("name").Context(ctx).Do() + if err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"topic": updated}) + } + u.Out().Printf("id\t%s", updated.TopicId) + u.Out().Printf("name\t%s", updated.Name) + return nil +} + +type ClassroomTopicsDeleteCmd struct { + CourseID string `arg:"" name:"courseId" help:"Course ID or alias"` + TopicID string `arg:"" name:"topicId" help:"Topic ID"` +} + +func (c *ClassroomTopicsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + courseID := strings.TrimSpace(c.CourseID) + topicID := strings.TrimSpace(c.TopicID) + if courseID == "" { + return usage("empty courseId") + } + if topicID == "" { + return usage("empty topicId") + } + + err = confirmDestructive(ctx, flags, fmt.Sprintf("delete topic %s from %s", topicID, courseID)) + if err != nil { + return err + } + + svc, err := newClassroomService(ctx, account) + if err != nil { + return wrapClassroomError(err) + } + + if _, err := svc.Courses.Topics.Delete(courseID, topicID).Context(ctx).Do(); err != nil { + return wrapClassroomError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "courseId": courseID, + "topicId": topicID, + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("course_id\t%s", courseID) + u.Out().Printf("topic_id\t%s", topicID) + return nil +} diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go index 1bcef1c12..80155fd17 100644 --- a/internal/cmd/gmail_watch_server.go +++ b/internal/cmd/gmail_watch_server.go @@ -468,10 +468,7 @@ func isStaleHistoryError(err error) bool { func isNotFoundAPIError(err error) bool { var gerr *googleapi.Error if errors.As(err, &gerr) { - if gerr.Code != http.StatusNotFound { - return false - } - return true + return gerr.Code == http.StatusNotFound } return false } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ecb95842d..a2856860e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -24,7 +24,7 @@ const ( type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/drive/docs/slides/contacts/tasks/people/sheets)"` + Account string `help:"Account email for API commands (gmail/calendar/classroom/drive/docs/slides/contacts/tasks/people/sheets)"` JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"` Plain bool `help:"Output stable, parseable text to stdout (TSV; no colors)" default:"${plain}"` Force bool `help:"Skip confirmations for destructive commands"` @@ -43,6 +43,7 @@ type CLI struct { Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"` Slides SlidesCmd `cmd:"" help:"Google Slides"` Calendar CalendarCmd `cmd:"" help:"Google Calendar"` + Classroom ClassroomCmd `cmd:"" help:"Google Classroom"` Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` Contacts ContactsCmd `cmd:"" help:"Google Contacts"` Tasks TasksCmd `cmd:"" help:"Google Tasks"` @@ -174,7 +175,7 @@ func boolString(v bool) string { } func helpDescription() string { - desc := "Google CLI for Gmail/Calendar/Drive/Contacts/Tasks/Sheets/Docs/Slides/People" + desc := "Google CLI for Gmail/Calendar/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People" configPath, err := config.ConfigPath() configLine := "unknown" diff --git a/internal/cmd/sheets_format.go b/internal/cmd/sheets_format.go index 84a872254..46830b1aa 100644 --- a/internal/cmd/sheets_format.go +++ b/internal/cmd/sheets_format.go @@ -44,18 +44,15 @@ func (c *SheetsFormatCmd) Run(ctx context.Context, flags *RootFlags) error { } var format sheets.CellFormat - if err := json.Unmarshal([]byte(c.FormatJSON), &format); err != nil { + if err = json.Unmarshal([]byte(c.FormatJSON), &format); err != nil { return fmt.Errorf("invalid format JSON: %w", err) } - normalizedFields, formatJSONPaths, err := normalizeFormatMask(formatFields) - if err != nil { - return err - } + normalizedFields, formatJSONPaths := normalizeFormatMask(formatFields) if normalizedFields != "" { formatFields = normalizedFields } - if err := applyForceSendFields(&format, formatJSONPaths); err != nil { + if err = applyForceSendFields(&format, formatJSONPaths); err != nil { return err } diff --git a/internal/cmd/sheets_format_fields.go b/internal/cmd/sheets_format_fields.go index ea047973e..933444821 100644 --- a/internal/cmd/sheets_format_fields.go +++ b/internal/cmd/sheets_format_fields.go @@ -8,10 +8,10 @@ import ( "google.golang.org/api/sheets/v4" ) -func normalizeFormatMask(mask string) (string, []string, error) { +func normalizeFormatMask(mask string) (string, []string) { parts := splitFieldMask(mask) if len(parts) == 0 { - return "", nil, nil + return "", nil } normalized := make([]string, 0, len(parts)) @@ -40,7 +40,7 @@ func normalizeFormatMask(mask string) (string, []string, error) { } } - return strings.Join(normalized, ","), formatJSONPaths, nil + return strings.Join(normalized, ","), formatJSONPaths } func applyForceSendFields(format *sheets.CellFormat, formatPaths []string) error { diff --git a/internal/cmd/sheets_format_fields_test.go b/internal/cmd/sheets_format_fields_test.go index 039631d44..882d37e53 100644 --- a/internal/cmd/sheets_format_fields_test.go +++ b/internal/cmd/sheets_format_fields_test.go @@ -55,10 +55,7 @@ func hasString(values []string, target string) bool { } func TestNormalizeFormatMask(t *testing.T) { - normalized, paths, err := normalizeFormatMask("textFormat.bold, userEnteredFormat.textFormat.italic, userEnteredValue") - if err != nil { - t.Fatalf("normalizeFormatMask: %v", err) - } + normalized, paths := normalizeFormatMask("textFormat.bold, userEnteredFormat.textFormat.italic, userEnteredValue") if normalized != "userEnteredFormat.textFormat.bold,userEnteredFormat.textFormat.italic,userEnteredValue" { t.Fatalf("unexpected normalized mask: %s", normalized) } @@ -68,10 +65,7 @@ func TestNormalizeFormatMask(t *testing.T) { } func TestNormalizeFormatMask_UserEnteredFormatOnly(t *testing.T) { - normalized, paths, err := normalizeFormatMask("userEnteredFormat") - if err != nil { - t.Fatalf("normalizeFormatMask: %v", err) - } + normalized, paths := normalizeFormatMask("userEnteredFormat") if normalized != "userEnteredFormat" { t.Fatalf("unexpected normalized mask: %s", normalized) } @@ -81,10 +75,7 @@ func TestNormalizeFormatMask_UserEnteredFormatOnly(t *testing.T) { } func TestNormalizeFormatMask_LeavesUnknowns(t *testing.T) { - normalized, paths, err := normalizeFormatMask("note") - if err != nil { - t.Fatalf("normalizeFormatMask: %v", err) - } + normalized, paths := normalizeFormatMask("note") if normalized != "note" { t.Fatalf("unexpected normalized mask: %s", normalized) } diff --git a/internal/googleapi/classroom.go b/internal/googleapi/classroom.go new file mode 100644 index 000000000..35fd1801a --- /dev/null +++ b/internal/googleapi/classroom.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/classroom/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewClassroom(ctx context.Context, email string) (*classroom.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceClassroom, email); err != nil { + return nil, fmt.Errorf("classroom options: %w", err) + } else if svc, err := classroom.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create classroom service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleapi/services_more_test.go b/internal/googleapi/services_more_test.go index 14c6a34fe..ee135a275 100644 --- a/internal/googleapi/services_more_test.go +++ b/internal/googleapi/services_more_test.go @@ -46,6 +46,10 @@ func TestNewServicesWithStoredToken(t *testing.T) { t.Fatalf("NewCalendar: %v", err) } + if _, err := NewClassroom(ctx, "a@b.com"); err != nil { + t.Fatalf("NewClassroom: %v", err) + } + if _, err := NewSheets(ctx, "a@b.com"); err != nil { t.Fatalf("NewSheets: %v", err) } diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 9439db3a8..5245656ff 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -10,16 +10,17 @@ import ( type Service string const ( - ServiceGmail Service = "gmail" - ServiceCalendar Service = "calendar" - ServiceDrive Service = "drive" - ServiceDocs Service = "docs" - ServiceContacts Service = "contacts" - ServiceTasks Service = "tasks" - ServicePeople Service = "people" - ServiceSheets Service = "sheets" - ServiceGroups Service = "groups" - ServiceKeep Service = "keep" + ServiceGmail Service = "gmail" + ServiceCalendar Service = "calendar" + ServiceClassroom Service = "classroom" + ServiceDrive Service = "drive" + ServiceDocs Service = "docs" + ServiceContacts Service = "contacts" + ServiceTasks Service = "tasks" + ServicePeople Service = "people" + ServiceSheets Service = "sheets" + ServiceGroups Service = "groups" + ServiceKeep Service = "keep" ) const ( @@ -56,6 +57,7 @@ type serviceInfo struct { var serviceOrder = []Service{ ServiceGmail, ServiceCalendar, + ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, @@ -69,7 +71,7 @@ var serviceOrder = []Service{ var serviceInfoByService = map[Service]serviceInfo{ ServiceGmail: { scopes: []string{ - "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic", "https://www.googleapis.com/auth/gmail.settings.sharing", }, @@ -81,6 +83,22 @@ var serviceInfoByService = map[Service]serviceInfo{ user: true, apis: []string{"Calendar API"}, }, + ServiceClassroom: { + scopes: []string{ + "https://www.googleapis.com/auth/classroom.courses", + "https://www.googleapis.com/auth/classroom.rosters", + "https://www.googleapis.com/auth/classroom.coursework.students", + "https://www.googleapis.com/auth/classroom.coursework.me", + "https://www.googleapis.com/auth/classroom.courseworkmaterials", + "https://www.googleapis.com/auth/classroom.announcements", + "https://www.googleapis.com/auth/classroom.topics", + "https://www.googleapis.com/auth/classroom.guardianlinks.students", + "https://www.googleapis.com/auth/classroom.profile.emails", + "https://www.googleapis.com/auth/classroom.profile.photos", + }, + user: true, + apis: []string{"Classroom API"}, + }, ServiceDrive: { scopes: []string{"https://www.googleapis.com/auth/drive"}, user: true, @@ -344,6 +362,23 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, return []string{"https://www.googleapis.com/auth/calendar.readonly"}, nil } + return Scopes(service) + case ServiceClassroom: + if opts.Readonly { + return []string{ + "https://www.googleapis.com/auth/classroom.courses.readonly", + "https://www.googleapis.com/auth/classroom.rosters.readonly", + "https://www.googleapis.com/auth/classroom.coursework.students.readonly", + "https://www.googleapis.com/auth/classroom.coursework.me.readonly", + "https://www.googleapis.com/auth/classroom.courseworkmaterials.readonly", + "https://www.googleapis.com/auth/classroom.announcements.readonly", + "https://www.googleapis.com/auth/classroom.topics.readonly", + "https://www.googleapis.com/auth/classroom.guardianlinks.students.readonly", + "https://www.googleapis.com/auth/classroom.profile.emails", + "https://www.googleapis.com/auth/classroom.profile.photos", + }, nil + } + return Scopes(service) case ServiceDrive: return []string{driveScopeValue()}, nil diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 053ece9fe..9fc17c6ec 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -10,6 +10,7 @@ func TestParseService(t *testing.T) { {"gmail", ServiceGmail}, {"GMAIL", ServiceGmail}, {"calendar", ServiceCalendar}, + {"classroom", ServiceClassroom}, {"drive", ServiceDrive}, {"docs", ServiceDocs}, {"contacts", ServiceContacts}, @@ -60,7 +61,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 10 { + if len(svcs) != 11 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -69,7 +70,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -78,7 +79,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 8 { + if len(svcs) != 9 { t.Fatalf("unexpected: %v", svcs) } @@ -98,7 +99,7 @@ func TestUserServices(t *testing.T) { } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,drive,docs,contacts,tasks,sheets,people" + want := "gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } @@ -188,7 +189,9 @@ func TestScopesForServices_UnionSorted(t *testing.T) { } // Ensure expected scopes are included. want := []string{ - "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/gmail.settings.sharing", "https://www.googleapis.com/auth/contacts", "https://www.googleapis.com/auth/contacts.other.readonly", "https://www.googleapis.com/auth/directory.readonly", @@ -240,6 +243,7 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) { notWant := []string{ "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic", "https://www.googleapis.com/auth/gmail.settings.sharing", "https://www.googleapis.com/auth/drive", @@ -402,7 +406,7 @@ func TestScopes_GmailIncludesSettingsSharing(t *testing.T) { } for _, want := range []string{ - "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic", "https://www.googleapis.com/auth/gmail.settings.sharing", } {