diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cf5ff..3383a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,17 @@ - Backup: extend `--services all` with Drive permissions/comments/revisions, Calendar ACL/settings/colors, contact groups, Cloud Identity groups, Workspace Admin Directory users/groups/members, Keep notes, and local Gmail message caching for resumable full-mailbox fetches. - Backup: bound individual Drive content exports with `--drive-content-timeout` so one stuck Google export records an encrypted error row instead of blocking the full backup. - Backup: add Gmail message-list checkpoints, streaming shard construction, and stderr progress counters so full-mailbox backups can resume cleanly after interruption without keeping every raw message in RAM. +- Calendar: add `--start-timezone` / `--end-timezone` to `calendar create` and `calendar update` for preserving named IANA event timezones when RFC3339 inputs only carry numeric offsets. (#422) ### Fixed - Calendar: display `calendar events` times and JSON local fields in the calendar timezone instead of preserving arbitrary event offsets. (#493) +- CLI: show direct Google Cloud API enablement links and matching `auth add --services ...` hints when Google returns API-not-enabled errors. - Gmail: apply Gmail system-label filters for searches like `in:spam is:unread` so thread, message, and batch message searches do not return read spam. (#449) - Gmail: build outbound `Date` headers with the configured timezone so replies do not inherit a wrong host-local offset. (#514, #472) — thanks @dinakars777. - Gmail: preserve renewed watch expiration fields when a long-running `gmail watch serve` process records push delivery state after `gmail watch renew` runs separately. (#526) - Gmail: auto-fill draft reply subjects from the original message when `gmail drafts create --reply-to-message-id` omits `--subject`. (#488) — thanks @jbowerbir. +- Gmail: reuse the shared paginated list runner for thread and message search so `--all`, `--page`, text, and JSON output stay consistent. +- Drive: print large upload progress to stderr while keeping JSON output parseable. (#529) - Drive: include `hasThumbnail` and `thumbnailLink` in `drive ls`, `drive search`, and `drive get` JSON responses. (#486) — thanks @gtapps. - Secrets: time out macOS Keychain read/write/list operations with a clear recovery hint instead of hanging indefinitely when a permission prompt cannot surface. (#515, #513) — thanks @sardoru. - Secrets: encode file-backend key names so stored tokens work on Windows, while still reading/removing legacy raw entries. (#527, #502) — thanks @solomonneas. diff --git a/README.md b/README.md index 763e94c..de2107f 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,11 @@ gog calendar create \ --attendees "alice@example.com,bob@example.com" \ --location "Zoom" +gog calendar create \ + --summary "Flight" \ + --from 2026-08-13T13:40:00+02:00 --start-timezone Europe/Rome \ + --to 2026-08-13T17:00:00-04:00 --end-timezone America/New_York + gog calendar update \ --summary "Updated Meeting" \ --from 2025-01-15T11:00:00Z \ @@ -984,6 +989,8 @@ gog drive upload ./report.docx --convert gog drive upload ./chart.png --convert-to sheet gog drive upload ./report.docx --convert --name report.docx gog drive upload ./notes.md --convert # Markdown → Google Doc (or use --convert-to doc) + +# Large non-JSON uploads print progress on stderr. gog drive download --out ./downloaded.bin gog drive download --format pdf --out ./exported.pdf # Google Workspace files only gog drive download --format docx --out ./doc.docx diff --git a/docs/spec.md b/docs/spec.md index 3d794a7..3128a35 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -190,7 +190,7 @@ Flag aliases: - `gog drive search [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]` - `gog drive get ` - `gog drive download [--out PATH] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown) -- `gog drive upload [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides] [--keep-frontmatter]` (Markdown → Google Doc with `--convert` or `--convert-to doc`: leading `---`/`---` frontmatter is stripped before upload unless `--keep-frontmatter`; delimiter-based, not a full YAML parse) +- `gog drive upload [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides] [--keep-frontmatter]` (Markdown → Google Doc with `--convert` or `--convert-to doc`: leading `---`/`---` frontmatter is stripped before upload unless `--keep-frontmatter`; delimiter-based, not a full YAML parse; large non-JSON uploads print progress to stderr) - `gog drive mkdir [--parent ID]` - `gog drive delete [--permanent]` - `gog drive move --parent ID` @@ -207,8 +207,8 @@ Flag aliases: - `gog calendar events [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]` - `gog calendar event|get ` - `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events` -- `gog calendar create --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` -- `gog calendar update [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]` +- `gog calendar create --summary S --from DT --to DT [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` +- `gog calendar update [--summary S] [--from DT] [--to DT] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]` - `gog calendar delete ` - `gog calendar freebusy [calendarIds] [--cal ID_OR_NAME] [--calendars CSV] [--all] --from RFC3339 --to RFC3339` - `gog calendar conflicts [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339|date|relative] [--to RFC3339|date|relative] [--today|--week|--days N]` diff --git a/internal/cmd/calendar_build.go b/internal/cmd/calendar_build.go index 4858ee2..f28bdfc 100644 --- a/internal/cmd/calendar_build.go +++ b/internal/cmd/calendar_build.go @@ -28,6 +28,22 @@ func buildEventDateTime(value string, allDay bool) *calendar.EventDateTime { return edt } +func buildEventDateTimeWithTimezone(value string, allDay bool, timezone, flagName string) (*calendar.EventDateTime, error) { + edt := buildEventDateTime(value, allDay) + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return edt, nil + } + if allDay { + return nil, usagef("%s cannot be used with all-day dates", flagName) + } + if _, err := loadTimezoneLocation(timezone); err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", flagName, timezone, err) + } + edt.TimeZone = timezone + return edt, nil +} + func etcGMTForOffsetSeconds(offset int) (string, bool) { if offset == 0 || offset%3600 != 0 { return "", false diff --git a/internal/cmd/calendar_build_test.go b/internal/cmd/calendar_build_test.go index cebafb7..06b7c1f 100644 --- a/internal/cmd/calendar_build_test.go +++ b/internal/cmd/calendar_build_test.go @@ -32,6 +32,33 @@ func TestExtractTimezone(t *testing.T) { } } +func TestBuildEventDateTimeWithTimezone(t *testing.T) { + edt, err := buildEventDateTimeWithTimezone( + "2026-08-13T13:40:00+02:00", + false, + "Europe/Rome", + "--start-timezone", + ) + if err != nil { + t.Fatalf("buildEventDateTimeWithTimezone: %v", err) + } + if edt.DateTime != "2026-08-13T13:40:00+02:00" { + t.Fatalf("unexpected datetime: %#v", edt) + } + if edt.TimeZone != "Europe/Rome" { + t.Fatalf("expected Europe/Rome timezone, got %#v", edt) + } +} + +func TestBuildEventDateTimeWithTimezoneRejectsInvalidInput(t *testing.T) { + if _, err := buildEventDateTimeWithTimezone("2026-08-13", true, "Europe/Rome", "--start-timezone"); err == nil { + t.Fatalf("expected all-day timezone error") + } + if _, err := buildEventDateTimeWithTimezone("2026-08-13T13:40:00+02:00", false, "Nope/Zone", "--start-timezone"); err == nil { + t.Fatalf("expected invalid timezone error") + } +} + func TestBuildAttachments(t *testing.T) { if got := buildAttachments(nil); got != nil { t.Fatalf("expected nil for empty input") diff --git a/internal/cmd/calendar_create_update_test.go b/internal/cmd/calendar_create_update_test.go index cc43adc..0ed13f8 100644 --- a/internal/cmd/calendar_create_update_test.go +++ b/internal/cmd/calendar_create_update_test.go @@ -183,6 +183,29 @@ func TestCalendarCreateCmd_RecurringOffsetTimezoneFallback(t *testing.T) { } } +func TestCalendarCreateCmd_ExplicitTimezones(t *testing.T) { + plan, err := buildCalendarCreatePlan(&CalendarCreateCmd{ + CalendarID: "primary", + Summary: "Flight", + From: "2026-08-13T13:40:00+02:00", + To: "2026-08-13T17:00:00-04:00", + StartTimezone: "Europe/Rome", + EndTimezone: "America/New_York", + SendUpdates: "none", + Transparency: "opaque", + Visibility: "default", + }) + if err != nil { + t.Fatalf("buildCalendarCreatePlan: %v", err) + } + if plan.Event.Start == nil || plan.Event.Start.TimeZone != "Europe/Rome" { + t.Fatalf("expected start timezone Europe/Rome, got %#v", plan.Event.Start) + } + if plan.Event.End == nil || plan.Event.End.TimeZone != "America/New_York" { + t.Fatalf("expected end timezone America/New_York, got %#v", plan.Event.End) + } +} + func TestCalendarUpdateCmd_RecurrenceFillsMissingTimezone(t *testing.T) { origNew := newCalendarService t.Cleanup(func() { newCalendarService = origNew }) diff --git a/internal/cmd/calendar_edit.go b/internal/cmd/calendar_edit.go index 302fa84..d78fda9 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -16,6 +16,8 @@ type CalendarCreateCmd struct { Summary string `name:"summary" help:"Event summary/title"` From string `name:"from" help:"Start time (RFC3339)"` To string `name:"to" help:"End time (RFC3339)"` + StartTimezone string `name:"start-timezone" aliases:"from-timezone" help:"IANA timezone metadata for --from (e.g., Europe/Rome)"` + EndTimezone string `name:"end-timezone" aliases:"to-timezone" help:"IANA timezone metadata for --to (e.g., America/New_York)"` Description string `name:"description" help:"Description"` Location string `name:"location" help:"Location"` Attendees string `name:"attendees" help:"Comma-separated attendee emails"` @@ -204,6 +206,8 @@ type CalendarUpdateCmd struct { Summary string `name:"summary" help:"New summary/title (set empty to clear)"` From string `name:"from" help:"New start time (RFC3339; set empty to clear)"` To string `name:"to" help:"New end time (RFC3339; set empty to clear)"` + StartTimezone string `name:"start-timezone" aliases:"from-timezone" help:"IANA timezone metadata for --from (e.g., Europe/Rome)"` + EndTimezone string `name:"end-timezone" aliases:"to-timezone" help:"IANA timezone metadata for --to (e.g., America/New_York)"` Description string `name:"description" help:"New description (set empty to clear)"` Location string `name:"location" help:"New location (set empty to clear)"` Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"` @@ -444,12 +448,21 @@ func resolveUpdateAllDay(value string, allDay bool, eventType string) (bool, err func (c *CalendarUpdateCmd) applyTimeFields(kctx *kong.Context, patch *calendar.Event, eventType string) (bool, error) { changed := false + if flagProvided(kctx, "start-timezone") && !flagProvided(kctx, "from") { + return false, usage("--start-timezone requires --from") + } + if flagProvided(kctx, "end-timezone") && !flagProvided(kctx, "to") { + return false, usage("--end-timezone requires --to") + } if flagProvided(kctx, "from") { allDay, err := resolveUpdateAllDay(c.From, c.AllDay, eventType) if err != nil { return false, err } - patch.Start = buildEventDateTime(c.From, allDay) + patch.Start, err = buildEventDateTimeWithTimezone(c.From, allDay, c.StartTimezone, "--start-timezone") + if err != nil { + return false, err + } changed = true } if flagProvided(kctx, "to") { @@ -457,7 +470,10 @@ func (c *CalendarUpdateCmd) applyTimeFields(kctx *kong.Context, patch *calendar. if err != nil { return false, err } - patch.End = buildEventDateTime(c.To, allDay) + patch.End, err = buildEventDateTimeWithTimezone(c.To, allDay, c.EndTimezone, "--end-timezone") + if err != nil { + return false, err + } changed = true } return changed, nil diff --git a/internal/cmd/calendar_edit_test.go b/internal/cmd/calendar_edit_test.go index 8827b15..fca72b0 100644 --- a/internal/cmd/calendar_edit_test.go +++ b/internal/cmd/calendar_edit_test.go @@ -79,3 +79,42 @@ func TestCalendarUpdatePatchClearsReminders(t *testing.T) { t.Fatalf("expected Reminders in ForceSendFields") } } + +func TestCalendarUpdatePatchExplicitTimezones(t *testing.T) { + cmd := &CalendarUpdateCmd{} + kctx := parseKongContext(t, cmd, []string{ + "cal1", + "evt1", + "--from", "2026-08-13T13:40:00+02:00", + "--to", "2026-08-13T17:00:00-04:00", + "--start-timezone", "Europe/Rome", + "--end-timezone", "America/New_York", + }) + + patch, changed, err := cmd.buildUpdatePatch(kctx) + if err != nil { + t.Fatalf("buildUpdatePatch: %v", err) + } + if !changed { + t.Fatalf("expected changed patch") + } + if patch.Start == nil || patch.Start.TimeZone != "Europe/Rome" { + t.Fatalf("expected start timezone Europe/Rome, got %#v", patch.Start) + } + if patch.End == nil || patch.End.TimeZone != "America/New_York" { + t.Fatalf("expected end timezone America/New_York, got %#v", patch.End) + } +} + +func TestCalendarUpdatePatchTimezoneRequiresTimeField(t *testing.T) { + cmd := &CalendarUpdateCmd{} + kctx := parseKongContext(t, cmd, []string{ + "cal1", + "evt1", + "--start-timezone", "Europe/Rome", + }) + + if _, _, err := cmd.buildUpdatePatch(kctx); err == nil { + t.Fatalf("expected --start-timezone without --from to fail") + } +} diff --git a/internal/cmd/calendar_event_plan.go b/internal/cmd/calendar_event_plan.go index 7168e69..e66f672 100644 --- a/internal/cmd/calendar_event_plan.go +++ b/internal/cmd/calendar_event_plan.go @@ -63,13 +63,21 @@ func buildCalendarCreatePlan(c *CalendarCreateCmd) (*calendarCreatePlan, error) if err != nil { return nil, err } + start, err := buildEventDateTimeWithTimezone(c.From, allDay, c.StartTimezone, "--start-timezone") + if err != nil { + return nil, err + } + end, err := buildEventDateTimeWithTimezone(c.To, allDay, c.EndTimezone, "--end-timezone") + if err != nil { + return nil, err + } event := &calendar.Event{ Summary: summary, Description: strings.TrimSpace(c.Description), Location: strings.TrimSpace(c.Location), - Start: buildEventDateTime(c.From, allDay), - End: buildEventDateTime(c.To, allDay), + Start: start, + End: end, Attendees: buildAttendees(c.Attendees), Recurrence: buildRecurrence(c.Recurrence), Reminders: reminders, diff --git a/internal/cmd/drive_upload.go b/internal/cmd/drive_upload.go index 0c91ada..1a39c27 100644 --- a/internal/cmd/drive_upload.go +++ b/internal/cmd/drive_upload.go @@ -27,6 +27,7 @@ type driveUploadOptions struct { isExplicitName bool keepRevisionForever bool convert bool + size int64 } func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -35,11 +36,13 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - media, err := openDriveUploadMedia(opts, c.KeepFrontmatter) + media, size, err := openDriveUploadMedia(opts, c.KeepFrontmatter) if err != nil { return err } defer media.Close() + opts.size = size + uploadReader := driveUploadReader(ctx, media, opts) _, svc, err := requireDriveService(ctx, flags) if err != nil { @@ -47,9 +50,9 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error { } if opts.replaceFileID == "" { - return runDriveCreateUpload(ctx, svc, media, opts) + return runDriveCreateUpload(ctx, svc, uploadReader, opts) } - return runDriveReplaceUpload(ctx, svc, media, opts) + return runDriveReplaceUpload(ctx, svc, uploadReader, opts) } func prepareDriveUpload(c *DriveUploadCmd) (driveUploadOptions, error) { @@ -99,24 +102,30 @@ func driveUploadShouldStripMarkdownFrontmatter(opts driveUploadOptions, keepFron return !keepFrontmatter && opts.convert && opts.mimeType == mimeTextMarkdown } -func openDriveUploadMedia(opts driveUploadOptions, keepFrontmatter bool) (io.ReadCloser, error) { +func openDriveUploadMedia(opts driveUploadOptions, keepFrontmatter bool) (io.ReadCloser, int64, error) { file, err := os.Open(opts.localPath) if err != nil { - return nil, err + return nil, 0, err } if !driveUploadShouldStripMarkdownFrontmatter(opts, keepFrontmatter) { - return file, nil + info, statErr := file.Stat() + if statErr != nil { + _ = file.Close() + return nil, 0, statErr + } + return file, info.Size(), nil } data, readErr := io.ReadAll(file) closeErr := file.Close() if readErr != nil { - return nil, readErr + return nil, 0, readErr } if closeErr != nil { - return nil, closeErr + return nil, 0, closeErr } - return io.NopCloser(bytes.NewReader(stripYAMLFrontmatter(data))), nil + stripped := stripYAMLFrontmatter(data) + return io.NopCloser(bytes.NewReader(stripped)), int64(len(stripped)), nil } func runDriveCreateUpload(ctx context.Context, svc *drive.Service, file io.Reader, opts driveUploadOptions) error { diff --git a/internal/cmd/drive_upload_progress.go b/internal/cmd/drive_upload_progress.go new file mode 100644 index 0000000..e36e911 --- /dev/null +++ b/internal/cmd/drive_upload_progress.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "io" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const ( + driveUploadProgressMinBytes = 1 << 20 + driveUploadProgressStep = 5 +) + +type driveUploadProgressReader struct { + reader io.Reader + size int64 + read int64 + nextPct int64 + logf func(string, ...any) + done bool +} + +func driveUploadReader(ctx context.Context, reader io.Reader, opts driveUploadOptions) io.Reader { + if outfmt.IsJSON(ctx) || opts.size < driveUploadProgressMinBytes { + return reader + } + u := ui.FromContext(ctx) + if u == nil || u.Err() == nil { + return reader + } + return &driveUploadProgressReader{ + reader: reader, + size: opts.size, + nextPct: driveUploadProgressStep, + logf: u.Err().Printf, + } +} + +func (r *driveUploadProgressReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if n > 0 { + r.read += int64(n) + r.report(false) + } + if err == io.EOF { + r.report(true) + } + return n, err +} + +func (r *driveUploadProgressReader) report(final bool) { + if r.size <= 0 || r.logf == nil { + return + } + if final && r.done { + return + } + pct := r.read * 100 / r.size + if pct > 100 { + pct = 100 + } + if !final && pct < r.nextPct { + return + } + if final && r.read < r.size { + return + } + + r.logf("upload: %s / %s (%d%%)", formatDriveSize(r.read), formatDriveSize(r.size), pct) + if final || pct >= 100 { + r.done = true + } + for r.nextPct <= pct { + r.nextPct += driveUploadProgressStep + } +} diff --git a/internal/cmd/drive_upload_progress_test.go b/internal/cmd/drive_upload_progress_test.go new file mode 100644 index 0000000..4661bd6 --- /dev/null +++ b/internal/cmd/drive_upload_progress_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestDriveUploadReader_ReportsProgressOnStderr(t *testing.T) { + var stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + data := bytes.Repeat([]byte("x"), driveUploadProgressMinBytes) + + wrapped := driveUploadReader(ctx, bytes.NewReader(data), driveUploadOptions{size: int64(len(data))}) + if _, err := io.ReadAll(wrapped); err != nil { + t.Fatalf("ReadAll: %v", err) + } + + out := stderr.String() + if !strings.Contains(out, "upload:") || !strings.Contains(out, "100%") { + t.Fatalf("expected upload progress, got %q", out) + } + if count := strings.Count(out, "100%"); count != 1 { + t.Fatalf("expected one final progress line, got %d in %q", count, out) + } +} + +func TestDriveUploadReader_SkipsProgressForJSON(t *testing.T) { + var stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + data := bytes.Repeat([]byte("x"), driveUploadProgressMinBytes) + + wrapped := driveUploadReader(ctx, bytes.NewReader(data), driveUploadOptions{size: int64(len(data))}) + if _, err := io.ReadAll(wrapped); err != nil { + t.Fatalf("ReadAll: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("expected no JSON-mode progress, got %q", stderr.String()) + } +} diff --git a/internal/cmd/gmail_archive.go b/internal/cmd/gmail_archive.go index 1a9d2ba..733b08e 100644 --- a/internal/cmd/gmail_archive.go +++ b/internal/cmd/gmail_archive.go @@ -172,17 +172,10 @@ func searchMessageIDs(ctx context.Context, svc *gmail.Service, query string, lim batchSize = 500 } - call := svc.Users.Messages.List("me"). - Q(query). - MaxResults(batchSize). + opts := newGmailSearchRequestOptions(query, batchSize, pageToken) + call := applyGmailMessageListOptions(svc.Users.Messages.List("me"), opts). Fields("messages(id),nextPageToken"). Context(ctx) - if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { - call = call.LabelIds(labelIDs...) - } - if pageToken != "" { - call = call.PageToken(pageToken) - } resp, err := call.Do() if err != nil { diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index 0298dd0..3097f46 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -51,17 +51,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro } fetch := func(pageToken string) ([]*gmail.Message, string, error) { - call := svc.Users.Messages.List("me"). - Q(query). - MaxResults(c.Max). + opts := newGmailSearchRequestOptions(query, c.Max, pageToken) + call := applyGmailMessageListOptions(svc.Users.Messages.List("me"), opts). Fields("messages(id,threadId),nextPageToken"). Context(ctx) - if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { - call = call.LabelIds(labelIDs...) - } - if strings.TrimSpace(pageToken) != "" { - call = call.PageToken(pageToken) - } resp, callErr := call.Do() if callErr != nil { return nil, "", callErr @@ -69,32 +62,17 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return resp.Messages, resp.NextPageToken, nil } - var messages []*gmail.Message - nextPageToken := "" - if c.All { - all, collectErr := collectAllPages(c.Page, fetch) - if collectErr != nil { - return collectErr - } - messages = all - } else { - messagesPage, pageToken, fetchErr := fetch(c.Page) - if fetchErr != nil { - return fetchErr - } - messages = messagesPage - nextPageToken = pageToken + messages, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch) + if err != nil { + return err } if len(messages) == 0 { if outfmt.IsJSON(ctx) { - if writeErr := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + return writePagedJSONResult(ctx, map[string]any{ "messages": []messageItem{}, "nextPageToken": nextPageToken, - }); writeErr != nil { - return writeErr - } - return failEmptyExit(c.FailEmpty) + }, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -116,16 +94,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro } if outfmt.IsJSON(ctx) { - if writeErr := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + return writePagedJSONResult(ctx, map[string]any{ "messages": items, "nextPageToken": nextPageToken, - }); writeErr != nil { - return writeErr - } - if len(items) == 0 { - return failEmptyExit(c.FailEmpty) - } - return nil + }, len(items), c.FailEmpty) } if len(items) == 0 { diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index b09a319..9d30137 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "os" "strings" "google.golang.org/api/gmail/v1" @@ -40,16 +39,8 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } fetch := func(pageToken string) ([]*gmail.Thread, string, error) { - call := svc.Users.Threads.List("me"). - Q(query). - MaxResults(c.Max). - Context(ctx) - if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { - call = call.LabelIds(labelIDs...) - } - if strings.TrimSpace(pageToken) != "" { - call = call.PageToken(pageToken) - } + opts := newGmailSearchRequestOptions(query, c.Max, pageToken) + call := applyGmailThreadListOptions(svc.Users.Threads.List("me"), opts).Context(ctx) resp, callErr := call.Do() if callErr != nil { return nil, "", callErr @@ -57,32 +48,17 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return resp.Threads, resp.NextPageToken, nil } - var threads []*gmail.Thread - nextPageToken := "" - if c.All { - all, collectErr := collectAllPages(c.Page, fetch) - if collectErr != nil { - return collectErr - } - threads = all - } else { - threadsPage, pageToken, fetchErr := fetch(c.Page) - if fetchErr != nil { - return fetchErr - } - threads = threadsPage - nextPageToken = pageToken + threads, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch) + if err != nil { + return err } if len(threads) == 0 { if outfmt.IsJSON(ctx) { - if writeErr := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + return writePagedJSONResult(ctx, map[string]any{ "threads": []threadItem{}, "nextPageToken": nextPageToken, - }); writeErr != nil { - return writeErr - } - return failEmptyExit(c.FailEmpty) + }, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -104,16 +80,10 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - if writeErr := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + return writePagedJSONResult(ctx, map[string]any{ "threads": items, "nextPageToken": nextPageToken, - }); writeErr != nil { - return writeErr - } - if len(items) == 0 { - return failEmptyExit(c.FailEmpty) - } - return nil + }, len(items), c.FailEmpty) } if len(items) == 0 { diff --git a/internal/cmd/gmail_search_request.go b/internal/cmd/gmail_search_request.go new file mode 100644 index 0000000..06fd377 --- /dev/null +++ b/internal/cmd/gmail_search_request.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "strings" + + "google.golang.org/api/gmail/v1" +) + +type gmailSearchRequestOptions struct { + query string + maxResults int64 + pageToken string + labelIDs []string +} + +func newGmailSearchRequestOptions(query string, maxResults int64, pageToken string) gmailSearchRequestOptions { + query = strings.TrimSpace(query) + return gmailSearchRequestOptions{ + query: query, + maxResults: maxResults, + pageToken: strings.TrimSpace(pageToken), + labelIDs: gmailQuerySystemLabelIDs(query), + } +} + +func applyGmailThreadListOptions(call *gmail.UsersThreadsListCall, opts gmailSearchRequestOptions) *gmail.UsersThreadsListCall { + call = call.Q(opts.query).MaxResults(opts.maxResults) + if len(opts.labelIDs) > 0 { + call = call.LabelIds(opts.labelIDs...) + } + if opts.pageToken != "" { + call = call.PageToken(opts.pageToken) + } + return call +} + +func applyGmailMessageListOptions(call *gmail.UsersMessagesListCall, opts gmailSearchRequestOptions) *gmail.UsersMessagesListCall { + call = call.Q(opts.query).MaxResults(opts.maxResults) + if len(opts.labelIDs) > 0 { + call = call.LabelIds(opts.labelIDs...) + } + if opts.pageToken != "" { + call = call.PageToken(opts.pageToken) + } + return call +} diff --git a/internal/errfmt/errfmt.go b/internal/errfmt/errfmt.go index 20211a9..7432be4 100644 --- a/internal/errfmt/errfmt.go +++ b/internal/errfmt/errfmt.go @@ -60,16 +60,7 @@ func Format(err error) string { var gerr *ggoogleapi.Error if errors.As(err, &gerr) { - reason := "" - if len(gerr.Errors) > 0 && gerr.Errors[0].Reason != "" { - reason = gerr.Errors[0].Reason - } - - if reason != "" { - return fmt.Sprintf("Google API error (%d %s): %s", gerr.Code, reason, gerr.Message) - } - - return fmt.Sprintf("Google API error (%d): %s", gerr.Code, gerr.Message) + return formatGoogleAPIError(gerr) } return err.Error() diff --git a/internal/errfmt/errfmt_test.go b/internal/errfmt/errfmt_test.go index 2f55561..54f2f2a 100644 --- a/internal/errfmt/errfmt_test.go +++ b/internal/errfmt/errfmt_test.go @@ -74,6 +74,26 @@ func TestFormat_GoogleAPIError(t *testing.T) { } } +func TestFormat_GoogleAPIError_AccessNotConfiguredHint(t *testing.T) { + err := &ggoogleapi.Error{ + Code: 403, + Message: "Google Drive API has not been used in project 123 before or it is disabled. " + + "Enable it by visiting https://console.developers.google.com/apis/api/drive.googleapis.com/overview?project=123", + Errors: []ggoogleapi.ErrorItem{ + {Reason: "accessNotConfigured"}, + }, + } + got := Format(err) + + if !containsAll(got, "Drive API is not enabled", "drive.googleapis.com", "--services drive") { + t.Fatalf("unexpected: %q", got) + } + + if strings.Contains(got, "Google API error") { + t.Fatalf("expected user-facing enablement hint, got: %q", got) + } +} + func TestFormat_KongParseError_UnknownFlag(t *testing.T) { // Use real Kong parser to generate a parse error type TestCmd struct { diff --git a/internal/errfmt/googleapi.go b/internal/errfmt/googleapi.go new file mode 100644 index 0000000..8562993 --- /dev/null +++ b/internal/errfmt/googleapi.go @@ -0,0 +1,115 @@ +package errfmt + +import ( + "fmt" + "regexp" + "strings" + + ggoogleapi "google.golang.org/api/googleapi" +) + +type googleAPIHint struct { + API string + DisplayName string + Service string +} + +var googleAPIHints = []googleAPIHint{ + {API: "admin.googleapis.com", DisplayName: "Admin SDK API", Service: "admin"}, + {API: "appsactivity.googleapis.com", DisplayName: "Drive Activity API", Service: "drive"}, + {API: "classroom.googleapis.com", DisplayName: "Classroom API", Service: "classroom"}, + {API: "cloudidentity.googleapis.com", DisplayName: "Cloud Identity API", Service: "groups"}, + {API: "docs.googleapis.com", DisplayName: "Docs API", Service: "docs"}, + {API: "drive.googleapis.com", DisplayName: "Drive API", Service: "drive"}, + {API: "forms.googleapis.com", DisplayName: "Forms API", Service: "forms"}, + {API: "gmail.googleapis.com", DisplayName: "Gmail API", Service: "gmail"}, + {API: "keep.googleapis.com", DisplayName: "Keep API", Service: "keep"}, + {API: "people.googleapis.com", DisplayName: "People API", Service: "contacts"}, + {API: "script.googleapis.com", DisplayName: "Apps Script API", Service: "appscript"}, + {API: "sheets.googleapis.com", DisplayName: "Sheets API", Service: "sheets"}, + {API: "slides.googleapis.com", DisplayName: "Slides API", Service: "slides"}, + {API: "tasks.googleapis.com", DisplayName: "Tasks API", Service: "tasks"}, +} + +var apiNamePattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9-]*\.googleapis\.com)\b`) + +func formatGoogleAPIError(gerr *ggoogleapi.Error) string { + reason := googleAPIErrorReason(gerr) + if googleAPIIsDisabled(gerr, reason) { + if hint, ok := googleAPIHintForError(gerr); ok { + return fmt.Sprintf( + "%s is not enabled for this OAuth project.\nEnable it at: %s\nThen retry the command. If you enabled it on a different OAuth client, re-authenticate with: gog auth add --services %s", + hint.DisplayName, + googleAPIEnableURL(hint.API), + hint.Service, + ) + } + } + + if reason != "" { + return fmt.Sprintf("Google API error (%d %s): %s", gerr.Code, reason, gerr.Message) + } + + return fmt.Sprintf("Google API error (%d): %s", gerr.Code, gerr.Message) +} + +func googleAPIErrorReason(gerr *ggoogleapi.Error) string { + if gerr == nil || len(gerr.Errors) == 0 { + return "" + } + + return strings.TrimSpace(gerr.Errors[0].Reason) +} + +func googleAPIIsDisabled(gerr *ggoogleapi.Error, reason string) bool { + if gerr == nil || gerr.Code != 403 { + return false + } + reason = strings.ToLower(strings.TrimSpace(reason)) + message := strings.ToLower(gerr.Message) + + return reason == "accessnotconfigured" || + strings.Contains(message, "has not been used") || + strings.Contains(message, "it is disabled") || + strings.Contains(message, "api has not been used") +} + +func googleAPIHintForError(gerr *ggoogleapi.Error) (googleAPIHint, bool) { + if gerr == nil { + return googleAPIHint{}, false + } + + message := strings.ToLower(gerr.Message) + for _, item := range gerr.Errors { + message += " " + strings.ToLower(item.Message) + } + + if match := apiNamePattern.FindStringSubmatch(message); len(match) == 2 { + if hint, ok := googleAPIHintForAPI(match[1]); ok { + return hint, true + } + } + + for _, hint := range googleAPIHints { + if strings.Contains(message, hint.API) || strings.Contains(message, strings.ToLower(hint.DisplayName)) { + return hint, true + } + } + + return googleAPIHint{}, false +} + +func googleAPIHintForAPI(api string) (googleAPIHint, bool) { + api = strings.ToLower(strings.TrimSpace(api)) + for _, hint := range googleAPIHints { + if hint.API == api { + return hint, true + } + } + + return googleAPIHint{}, false +} + +func googleAPIEnableURL(api string) string { + return "https://console.developers.google.com/apis/api/" + api + "/overview" +}