feat(cli): improve google api hints and upload/timezone handling

This commit is contained in:
Peter Steinberger 2026-04-27 20:29:39 +01:00
parent 7f1ef97e15
commit 28d9e9873a
No known key found for this signature in database
19 changed files with 498 additions and 111 deletions

View File

@ -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.

View File

@ -874,6 +874,11 @@ gog calendar create <calendarId> \
--attendees "alice@example.com,bob@example.com" \
--location "Zoom"
gog calendar create <calendarId> \
--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 <calendarId> <eventId> \
--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 <fileId> --out ./downloaded.bin
gog drive download <fileId> --format pdf --out ./exported.pdf # Google Workspace files only
gog drive download <fileId> --format docx --out ./doc.docx

View File

@ -190,7 +190,7 @@ Flag aliases:
- `gog drive search <text> [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]`
- `gog drive get <fileId>`
- `gog drive download <fileId> [--out PATH] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown)
- `gog drive upload <localPath> [--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 <localPath> [--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 <name> [--parent ID]`
- `gog drive delete <fileId> [--permanent]`
- `gog drive move <fileId> --parent ID`
@ -207,8 +207,8 @@ Flag aliases:
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `gog calendar event|get <calendarId> <eventId>`
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
- `gog calendar create <calendarId> --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 <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]`
- `gog calendar create <calendarId> --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 <calendarId> <eventId> [--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 <calendarId> <eventId>`
- `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]`

View File

@ -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

View File

@ -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")

View File

@ -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 })

View File

@ -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

View File

@ -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")
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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()

View File

@ -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 {

View File

@ -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 <account> --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"
}