diff --git a/CHANGELOG.md b/CHANGELOG.md index 24affee..95806dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixed + +- Paths: expand leading `~` in user-provided file paths (e.g. `--out "~/Downloads/file.pdf"`). (#56) — thanks @salmonumbrella. +- Calendar: accept ISO 8601 timezones without colon (e.g. `-0800`) and add `gog calendar list` alias. (#56) — thanks @salmonumbrella. + ## 0.5.3 - 2026-01-10 ### Fixed diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index a7d0ddd..14f5968 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -32,7 +32,7 @@ func ensureKeychainAccessIfNeeded() error { if err != nil { return fmt.Errorf("resolve keyring backend: %w", err) } - if backendInfo.Value == "file" { + if backendInfo.Value == strFile { return nil } return ensureKeychainAccess() @@ -67,6 +67,10 @@ func (c *AuthCredentialsCmd) Run(ctx context.Context) error { if inPath == "-" { b, err = io.ReadAll(os.Stdin) } else { + inPath, err = config.ExpandPath(inPath) + if err != nil { + return err + } b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path } if err != nil { @@ -184,6 +188,10 @@ func (c *AuthTokensExportCmd) Run(ctx context.Context) error { if outPath == "" { return usage("empty outPath") } + outPath, err := config.ExpandPath(outPath) + if err != nil { + return err + } store, err := openSecretsStore() if err != nil { @@ -259,6 +267,10 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error { if inPath == "-" { b, err = io.ReadAll(os.Stdin) } else { + inPath, err = config.ExpandPath(inPath) + if err != nil { + return err + } b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path } if err != nil { @@ -608,6 +620,10 @@ func (c *AuthKeepCmd) Run(ctx context.Context) error { if keyPath == "" { return usage("empty key path") } + keyPath, err := config.ExpandPath(keyPath) + if err != nil { + return err + } data, err := os.ReadFile(keyPath) //nolint:gosec // user-provided path if err != nil { diff --git a/internal/cmd/auth_keyring.go b/internal/cmd/auth_keyring.go index fff87e2..4db69a3 100644 --- a/internal/cmd/auth_keyring.go +++ b/internal/cmd/auth_keyring.go @@ -69,7 +69,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error { allowed := map[string]struct{}{ "auto": {}, "keychain": {}, - "file": {}, + strFile: {}, } if _, ok := allowed[backend]; !ok { return usagef("invalid backend: %q (expected auto, keychain, or file)", c.Backend) @@ -94,7 +94,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error { u.Err().Printf("NOTE: GOG_KEYRING_BACKEND=%s overrides config.json", v) } - if backend == "file" && + if backend == strFile && u != nil && !outfmt.IsJSON(ctx) && !outfmt.IsPlain(ctx) { diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index c693e84..a44e984 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -13,7 +13,7 @@ import ( type CalendarCmd struct { Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` ACL CalendarAclCmd `cmd:"" name:"acl" help:"List calendar ACL"` - Events CalendarEventsCmd `cmd:"" name:"events" help:"List events from a calendar or all calendars"` + Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list" help:"List events from a calendar or all calendars"` Event CalendarEventCmd `cmd:"" name:"event" help:"Get event"` Create CalendarCreateCmd `cmd:"" name:"create" help:"Create an event"` Update CalendarUpdateCmd `cmd:"" name:"update" help:"Update an event"` diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 2a4333b..1adac19 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -90,7 +90,7 @@ func (c *DocsInfoCmd) Run(ctx context.Context, flags *RootFlags) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{ - "file": file, + strFile: file, "document": doc, }) } @@ -151,7 +151,7 @@ func (c *DocsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created}) } u.Out().Printf("id\t%s", created.Id) diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index ac146e2..b8932a3 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -13,6 +13,7 @@ import ( "google.golang.org/api/drive/v3" gapi "google.golang.org/api/googleapi" + "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/googleapi" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" @@ -225,7 +226,7 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f}) } u.Out().Printf("id\t%s", f.Id) @@ -330,6 +331,10 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error { if localPath == "" { return usage("empty localPath") } + localPath, err = config.ExpandPath(localPath) + if err != nil { + return err + } f, err := os.Open(localPath) //nolint:gosec // user-provided path if err != nil { @@ -365,7 +370,7 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created}) } u.Out().Printf("id\t%s", created.Id) @@ -513,7 +518,7 @@ func (c *DriveMoveCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated}) } u.Out().Printf("id\t%s", updated.Id) @@ -556,7 +561,7 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated}) } u.Out().Printf("id\t%s", updated.Id) @@ -818,7 +823,7 @@ func driveType(mimeType string) string { if mimeType == "application/vnd.google-apps.folder" { return "folder" } - return "file" //nolint:goconst // readability; used as a display value and JSON key elsewhere. + return strFile } func formatDateTime(iso string) string { diff --git a/internal/cmd/drive_copy.go b/internal/cmd/drive_copy.go index 3e015ab..ed4d7af 100644 --- a/internal/cmd/drive_copy.go +++ b/internal/cmd/drive_copy.go @@ -82,7 +82,7 @@ func copyViaDrive(ctx context.Context, flags *RootFlags, opts copyViaDriveOption } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created}) } u.Out().Printf("id\t%s", created.Id) u.Out().Printf("name\t%s", created.Name) diff --git a/internal/cmd/drive_download_helpers.go b/internal/cmd/drive_download_helpers.go index 601d243..bd4c56e 100644 --- a/internal/cmd/drive_download_helpers.go +++ b/internal/cmd/drive_download_helpers.go @@ -24,6 +24,14 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string, } destPath := strings.TrimSpace(outPathFlag) + // Expand ~ to home directory (shell doesn't expand when path is quoted). + if destPath != "" { + expanded, err := config.ExpandPath(destPath) + if err != nil { + return "", err + } + destPath = expanded + } // Sanitize filename to prevent path traversal. safeName := filepath.Base(meta.Name) if safeName == "" || safeName == "." || safeName == ".." { diff --git a/internal/cmd/gmail_attachment.go b/internal/cmd/gmail_attachment.go index 0539000..4e07f6d 100644 --- a/internal/cmd/gmail_attachment.go +++ b/internal/cmd/gmail_attachment.go @@ -71,7 +71,11 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } - path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, c.Output.Path, -1) + outPath, err := config.ExpandPath(c.Output.Path) + if err != nil { + return err + } + path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, outPath, -1) if err != nil { return err } diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index 08528f4..a262b0e 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -331,7 +331,11 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, atts := make([]mailAttachment, 0, len(input.Attach)) for _, p := range input.Attach { - atts = append(atts, mailAttachment{Path: p}) + expanded, expandErr := config.ExpandPath(p) + if expandErr != nil { + return nil, "", expandErr + } + atts = append(atts, mailAttachment{Path: expanded}) } raw, err := buildRFC822(mailOptions{ diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 931a693..ac59f7f 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -10,6 +10,7 @@ import ( "google.golang.org/api/gmail/v1" + "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/tracking" "github.com/steipete/gogcli/internal/ui" @@ -147,7 +148,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { atts := make([]mailAttachment, 0, len(c.Attach)) for _, p := range c.Attach { - atts = append(atts, mailAttachment{Path: p}) + expanded, expandErr := config.ExpandPath(p) + if expandErr != nil { + return expandErr + } + atts = append(atts, mailAttachment{Path: expanded}) } var trackingCfg *tracking.Config diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go index 8ff0804..d7dc530 100644 --- a/internal/cmd/gmail_thread.go +++ b/internal/cmd/gmail_thread.go @@ -14,6 +14,7 @@ import ( "google.golang.org/api/gmail/v1" + "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) @@ -81,7 +82,11 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error { // Default: current directory, not gogcli config dir. attachDir = "." } else { - attachDir = filepath.Clean(c.OutputDir.Dir) + expanded, err := config.ExpandPath(c.OutputDir.Dir) + if err != nil { + return err + } + attachDir = filepath.Clean(expanded) } } @@ -292,7 +297,11 @@ func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) e if strings.TrimSpace(c.OutputDir.Dir) == "" { attachDir = "." } else { - attachDir = filepath.Clean(c.OutputDir.Dir) + expanded, err := config.ExpandPath(c.OutputDir.Dir) + if err != nil { + return err + } + attachDir = filepath.Clean(expanded) } } diff --git a/internal/cmd/info_via_drive.go b/internal/cmd/info_via_drive.go index 34699f0..4941028 100644 --- a/internal/cmd/info_via_drive.go +++ b/internal/cmd/info_via_drive.go @@ -60,7 +60,7 @@ func infoViaDrive(ctx context.Context, flags *RootFlags, opts infoViaDriveOption } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f}) } u.Out().Printf("id\t%s", f.Id) diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index 8690cf7..b43b6ae 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -90,7 +90,7 @@ func (c *SlidesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created}) + return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created}) } u.Out().Printf("id\t%s", created.Id) diff --git a/internal/cmd/strings.go b/internal/cmd/strings.go new file mode 100644 index 0000000..195cf82 --- /dev/null +++ b/internal/cmd/strings.go @@ -0,0 +1,5 @@ +package cmd + +const ( + strFile = "file" +) diff --git a/internal/cmd/time_helpers.go b/internal/cmd/time_helpers.go index d8c9ec1..ea08c6a 100644 --- a/internal/cmd/time_helpers.go +++ b/internal/cmd/time_helpers.go @@ -130,6 +130,7 @@ func ResolveTimeRangeWithDefaults(ctx context.Context, svc *calendar.Service, fl // parseTimeExpr parses a time expression which can be: // - RFC3339: 2026-01-05T14:00:00-08:00 +// - ISO 8601 with numeric timezone: 2026-01-05T14:00:00-0800 (no colon) // - Date only: 2026-01-05 (interpreted as start of day in user's timezone) // - Relative: today, tomorrow, monday, next tuesday func parseTimeExpr(expr string, now time.Time, loc *time.Location) (time.Time, error) { @@ -140,6 +141,12 @@ func parseTimeExpr(expr string, now time.Time, loc *time.Location) (time.Time, e return t, nil } + // Try ISO 8601 with numeric timezone without colon (e.g., -0800) + // This is what macOS `date +%Y-%m-%dT%H:%M:%S%z` produces + if t, err := time.Parse("2006-01-02T15:04:05-0700", expr); err == nil { + return t, nil + } + // Now lowercase for relative expressions exprLower := strings.ToLower(expr) diff --git a/internal/cmd/time_helpers_test.go b/internal/cmd/time_helpers_test.go index 97c2ae4..e53b843 100644 --- a/internal/cmd/time_helpers_test.go +++ b/internal/cmd/time_helpers_test.go @@ -44,6 +44,19 @@ func TestParseTimeExprMore(t *testing.T) { t.Fatalf("expected UTC location, got %v", parsed.Location()) } + // Test ISO 8601 with numeric timezone without colon (macOS date +%z format) + parsed, err = parseTimeExpr("2025-01-09T16:38:41-0800", now, loc) + if err != nil { + t.Fatalf("parseTimeExpr iso8601 numeric tz: %v", err) + } + if parsed.Hour() != 16 || parsed.Minute() != 38 || parsed.Second() != 41 { + t.Fatalf("unexpected iso8601 numeric tz time: %v", parsed) + } + _, offset := parsed.Zone() + if offset != -8*3600 { + t.Fatalf("unexpected iso8601 numeric tz offset: %d", offset) + } + parsed, err = parseTimeExpr("yesterday", now, loc) if err != nil { t.Fatalf("parseTimeExpr yesterday: %v", err) diff --git a/internal/config/paths.go b/internal/config/paths.go index 4b40935..6c0e432 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -153,3 +153,32 @@ func EnsureGmailWatchDir() (string, error) { return dir, nil } + +// ExpandPath expands ~ at the beginning of a path to the user's home directory. +// This is needed because ~ is a shell feature and is not expanded when paths +// are quoted (e.g., --out "~/Downloads/file.pdf"). +func ExpandPath(path string) (string, error) { + if path == "" { + return "", nil + } + + if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand home dir: %w", err) + } + + return home, nil + } + + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand home dir: %w", err) + } + + return filepath.Join(home, path[2:]), nil + } + + return path, nil +} diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index 88af828..77429fe 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -71,6 +71,63 @@ func TestPaths_CreateDirs(t *testing.T) { } } +func TestExpandPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "empty path", + input: "", + want: "", + }, + { + name: "tilde only", + input: "~", + want: home, + }, + { + name: "tilde with subpath", + input: "~/Downloads/file.txt", + want: filepath.Join(home, "Downloads/file.txt"), + }, + { + name: "absolute path unchanged", + input: "/usr/local/bin", + want: "/usr/local/bin", + }, + { + name: "relative path unchanged", + input: "relative/path/file.txt", + want: "relative/path/file.txt", + }, + { + name: "tilde in middle unchanged", + input: "/some/~/path", + want: "/some/~/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExpandPath(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ExpandPath() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("ExpandPath() = %q, want %q", got, tt.want) + } + }) + } +} + func TestKeepServiceAccountPath_SafeFilename(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home)