fix: adjust classroom topic scan + manage upgrade scopes (#73) (thanks @salmonumbrella)

This commit is contained in:
Peter Steinberger 2026-01-17 01:56:32 +00:00
parent ce44ca2620
commit f5f33ca7a6
8 changed files with 320 additions and 84 deletions

View File

@ -6,7 +6,9 @@
- 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.
- Auth: account manager upgrade respects managed services and skips Keep OAuth scopes. (#73) — thanks @salmonumbrella.
- Classroom: normalize assignee updates + fix grade update masks. (#74) — thanks @salmonumbrella.
- Classroom: scan pages when filtering coursework/materials by topic. (#73) — thanks @salmonumbrella.
### Build

View File

@ -185,13 +185,13 @@ Flag aliases:
- `gog classroom teachers add <courseId> <userId>`
- `gog classroom teachers remove <courseId> <userId>`
- `gog classroom roster <courseId> [--students] [--teachers]`
- `gog classroom coursework <courseId> [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]`
- `gog classroom coursework <courseId> [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]`
- `gog classroom coursework get <courseId> <courseworkId>`
- `gog classroom coursework create <courseId> --title TITLE [--type ASSIGNMENT|...]`
- `gog classroom coursework update <courseId> <courseworkId> [--title ...]`
- `gog classroom coursework delete <courseId> <courseworkId>`
- `gog classroom coursework assignees <courseId> <courseworkId> [--mode ...] [--add-student ...]`
- `gog classroom materials <courseId> [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]`
- `gog classroom materials <courseId> [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]`
- `gog classroom materials get <courseId> <materialId>`
- `gog classroom materials create <courseId> --title TITLE`
- `gog classroom materials update <courseId> <materialId> [--title ...]`

View File

@ -22,12 +22,13 @@ type ClassroomCourseworkCmd struct {
}
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"`
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"`
ScanPages int `name:"scan-pages" help:"Pages to scan when filtering by topic" default:"3"`
}
func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -46,45 +47,71 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
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))
makeCall := func(page string) *classroom.CoursesCourseWorkListCall {
call := svc.Courses.CourseWork.List(courseID).PageSize(c.Max).PageToken(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...)
}
call.CourseWorkStates(upper...)
}
if v := strings.TrimSpace(c.OrderBy); v != "" {
call.OrderBy(v)
if v := strings.TrimSpace(c.OrderBy); v != "" {
call.OrderBy(v)
}
return call
}
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 {
pageToken := c.Page
scanPages := c.ScanPages
if scanPages <= 0 {
scanPages = 1
}
var (
coursework []*classroom.CourseWork
nextPageToken string
)
for page := 0; ; page++ {
resp, err := makeCall(pageToken).Do()
if err != nil {
return wrapClassroomError(err)
}
nextPageToken = resp.NextPageToken
if topicFilter == "" {
coursework = resp.CourseWork
break
}
filtered := make([]*classroom.CourseWork, 0, len(resp.CourseWork))
for _, work := range resp.CourseWork {
if work != nil && work.TopicId == topicFilter {
filtered = append(filtered, work)
}
}
coursework = filtered
if len(filtered) > 0 {
coursework = filtered
break
}
if nextPageToken == "" || page+1 >= scanPages {
coursework = filtered
break
}
pageToken = nextPageToken
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"coursework": coursework,
"nextPageToken": resp.NextPageToken,
"nextPageToken": nextPageToken,
})
}
if len(coursework) == 0 {
u.Err().Println("No coursework")
printNextPageHint(u, nextPageToken)
return nil
}
@ -104,7 +131,7 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
formatFloatValue(work.MaxPoints),
)
}
printNextPageHint(u, resp.NextPageToken)
printNextPageHint(u, nextPageToken)
return nil
}

View File

@ -21,12 +21,13 @@ type ClassroomMaterialsCmd struct {
}
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"`
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"`
ScanPages int `name:"scan-pages" help:"Pages to scan when filtering by topic" default:"3"`
}
func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -45,45 +46,71 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
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))
makeCall := func(page string) *classroom.CoursesCourseWorkMaterialsListCall {
call := svc.Courses.CourseWorkMaterials.List(courseID).PageSize(c.Max).PageToken(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...)
}
call.CourseWorkMaterialStates(upper...)
}
if v := strings.TrimSpace(c.OrderBy); v != "" {
call.OrderBy(v)
if v := strings.TrimSpace(c.OrderBy); v != "" {
call.OrderBy(v)
}
return call
}
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 {
pageToken := c.Page
scanPages := c.ScanPages
if scanPages <= 0 {
scanPages = 1
}
var (
materials []*classroom.CourseWorkMaterial
nextPageToken string
)
for page := 0; ; page++ {
resp, err := makeCall(pageToken).Do()
if err != nil {
return wrapClassroomError(err)
}
nextPageToken = resp.NextPageToken
if topicFilter == "" {
materials = resp.CourseWorkMaterial
break
}
filtered := make([]*classroom.CourseWorkMaterial, 0, len(resp.CourseWorkMaterial))
for _, material := range resp.CourseWorkMaterial {
if material != nil && material.TopicId == topicFilter {
filtered = append(filtered, material)
}
}
materials = filtered
if len(filtered) > 0 {
materials = filtered
break
}
if nextPageToken == "" || page+1 >= scanPages {
materials = filtered
break
}
pageToken = nextPageToken
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"materials": materials,
"nextPageToken": resp.NextPageToken,
"nextPageToken": nextPageToken,
})
}
if len(materials) == 0 {
u.Err().Println("No materials")
printNextPageHint(u, nextPageToken)
return nil
}
@ -101,7 +128,7 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
sanitizeTab(material.UpdateTime),
)
}
printNextPageHint(u, resp.NextPageToken)
printNextPageHint(u, nextPageToken)
return nil
}

View File

@ -0,0 +1,143 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/classroom/v1"
"google.golang.org/api/option"
)
func TestClassroomCourseworkList_TopicScanPages(t *testing.T) {
origNew := newClassroomService
t.Cleanup(func() { newClassroomService = origNew })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/courseWork") {
http.NotFound(w, r)
return
}
calls++
w.Header().Set("Content-Type", "application/json")
switch calls {
case 1:
_ = json.NewEncoder(w).Encode(map[string]any{
"courseWork": []map[string]any{{"id": "w1", "topicId": "other"}},
"nextPageToken": "p2",
})
case 2:
_ = json.NewEncoder(w).Encode(map[string]any{
"courseWork": []map[string]any{{"id": "w2", "topicId": "target"}},
"nextPageToken": "",
})
default:
t.Fatalf("unexpected coursework calls: %d", calls)
}
}))
defer srv.Close()
svc, err := classroom.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil }
var payload struct {
Coursework []struct {
ID string `json:"id"`
} `json:"coursework"`
NextPageToken string `json:"nextPageToken"`
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "classroom", "coursework", "c1", "--topic", "target", "--scan-pages", "2"}); err != nil {
t.Fatalf("execute: %v", err)
}
})
})
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(payload.Coursework) != 1 || payload.Coursework[0].ID != "w2" {
t.Fatalf("expected coursework w2, got %#v", payload.Coursework)
}
if calls != 2 {
t.Fatalf("expected 2 calls, got %d", calls)
}
}
func TestClassroomMaterialsList_TopicScanPages(t *testing.T) {
origNew := newClassroomService
t.Cleanup(func() { newClassroomService = origNew })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/courseWorkMaterials") {
http.NotFound(w, r)
return
}
calls++
w.Header().Set("Content-Type", "application/json")
switch calls {
case 1:
_ = json.NewEncoder(w).Encode(map[string]any{
"courseWorkMaterial": []map[string]any{{"id": "m1", "topicId": "other"}},
"nextPageToken": "p2",
})
case 2:
_ = json.NewEncoder(w).Encode(map[string]any{
"courseWorkMaterial": []map[string]any{{"id": "m2", "topicId": "target"}},
"nextPageToken": "",
})
default:
t.Fatalf("unexpected materials calls: %d", calls)
}
}))
defer srv.Close()
svc, err := classroom.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil }
var payload struct {
Materials []struct {
ID string `json:"id"`
} `json:"materials"`
NextPageToken string `json:"nextPageToken"`
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "classroom", "materials", "c1", "--topic", "target", "--scan-pages", "2"}); err != nil {
t.Fatalf("execute: %v", err)
}
})
})
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(payload.Materials) != 1 || payload.Materials[0].ID != "m2" {
t.Fatalf("expected material m2, got %#v", payload.Materials)
}
if calls != 2 {
t.Fatalf("expected 2 calls, got %d", calls)
}
}

View File

@ -123,8 +123,8 @@ func TestExecute_ClassroomMoreCommands_JSON(t *testing.T) {
writeJSON(map[string]any{"ok": true})
return
case strings.Contains(path, "/studentSubmissions/") && r.Method == http.MethodPatch:
if got := r.URL.Query().Get("updateMask"); got != "draft_grade,assigned_grade" {
t.Fatalf("expected updateMask draft_grade,assigned_grade, got %q", got)
if got := r.URL.Query().Get("updateMask"); got != "draftGrade,assignedGrade" {
t.Fatalf("expected updateMask draftGrade,assignedGrade, got %q", got)
}
writeJSON(map[string]any{"id": "s1", "draftGrade": 5, "assignedGrade": 10})
return

View File

@ -74,6 +74,26 @@ func shouldEnsureKeychainAccess() (bool, error) {
return backendInfo.Value != "file", nil
}
func manageServices(services []Service) []Service {
if len(services) == 0 {
services = UserServices()
}
filtered := make([]Service, 0, len(services))
for _, svc := range services {
if svc == ServiceKeep {
continue
}
filtered = append(filtered, svc)
}
if len(filtered) == 0 {
return UserServices()
}
return filtered
}
// StartManageServer starts the accounts management server and opens browser
func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
if opts.Timeout <= 0 {
@ -220,10 +240,7 @@ func (ms *ManageServer) handleAuthStart(w http.ResponseWriter, r *http.Request)
}
ms.oauthState = state
services := ms.opts.Services
if len(services) == 0 {
services = AllServices()
}
services := manageServices(ms.opts.Services)
scopes, err := ScopesForManage(services)
if err != nil {
@ -267,8 +284,8 @@ func (ms *ManageServer) handleAuthUpgrade(w http.ResponseWriter, r *http.Request
}
ms.oauthState = state
// Always use all services for upgrade
services := AllServices()
// Use requested manage services (exclude Keep)
services := manageServices(ms.opts.Services)
scopes, err := ScopesForManage(services)
if err != nil {
@ -331,10 +348,7 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
return
}
services := ms.opts.Services
if len(services) == 0 {
services = AllServices()
}
services := manageServices(ms.opts.Services)
scopes, err := ScopesForManage(services)
if err != nil {
@ -618,9 +632,11 @@ func renderSuccessPageWithDetails(w http.ResponseWriter, email string, services
return
}
// Get all available services for showing connected vs missing
allServices := make([]string, 0, len(serviceOrder))
for _, svc := range serviceOrder {
// Show available user services for connected vs missing
userServices := UserServices()
allServices := make([]string, 0, len(userServices))
for _, svc := range userServices {
allServices = append(allServices, string(svc))
}

View File

@ -428,12 +428,10 @@ func TestManageServer_HandleAuthStart(t *testing.T) {
t.Fatalf("status: %d", rr.Code)
}
loc := rr.Header().Get("Location")
var parsed *url.URL
if p, err := url.Parse(loc); err != nil {
t.Fatalf("parse location: %v", err)
} else {
parsed = p
parsed, parseErr := url.Parse(loc)
if parseErr != nil {
t.Fatalf("parse location: %v", parseErr)
}
if parsed.Host != "example.com" {
@ -884,7 +882,10 @@ func TestManageServer_HandleAuthUpgrade(t *testing.T) {
t.Cleanup(func() { _ = ln.Close() })
ms := &ManageServer{listener: ln}
ms := &ManageServer{
listener: ln,
opts: ManageServerOptions{Services: []Service{ServiceGmail}},
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/auth/upgrade?email=test@example.com", nil)
ms.handleAuthUpgrade(rr, req)
@ -894,12 +895,10 @@ func TestManageServer_HandleAuthUpgrade(t *testing.T) {
}
loc := rr.Header().Get("Location")
var parsed *url.URL
if p, err := url.Parse(loc); err != nil {
t.Fatalf("parse location: %v", err)
} else {
parsed = p
parsed, parseErr := url.Parse(loc)
if parseErr != nil {
t.Fatalf("parse location: %v", parseErr)
}
if parsed.Host != "example.com" {
@ -914,6 +913,28 @@ func TestManageServer_HandleAuthUpgrade(t *testing.T) {
t.Fatalf("expected oauthState set")
}
scope := parsed.Query().Get("scope")
expectedScopes, err := ScopesForManage([]Service{ServiceGmail})
if err != nil {
t.Fatalf("ScopesForManage: %v", err)
}
scopeSet := make(map[string]bool, len(expectedScopes))
for _, s := range strings.Fields(scope) {
scopeSet[s] = true
}
for _, s := range expectedScopes {
if !scopeSet[s] {
t.Fatalf("expected scope %q in %q", s, scope)
}
}
if scopeSet["https://www.googleapis.com/auth/keep.readonly"] {
t.Fatalf("unexpected keep scope in %q", scope)
}
// Check for login_hint (pre-selects the email)
if loginHint := parsed.Query().Get("login_hint"); loginHint != "test@example.com" {
t.Fatalf("expected login_hint=test@example.com, got %q", loginHint)