feat(cli): improve google api hints and upload/timezone handling
This commit is contained in:
parent
7f1ef97e15
commit
28d9e9873a
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
78
internal/cmd/drive_upload_progress.go
Normal file
78
internal/cmd/drive_upload_progress.go
Normal 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
|
||||
}
|
||||
}
|
||||
53
internal/cmd/drive_upload_progress_test.go
Normal file
53
internal/cmd/drive_upload_progress_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
46
internal/cmd/gmail_search_request.go
Normal file
46
internal/cmd/gmail_search_request.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
115
internal/errfmt/googleapi.go
Normal file
115
internal/errfmt/googleapi.go
Normal 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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user