From 4c674ce10aba2a7be907b050b194458dfcc7a3a5 Mon Sep 17 00:00:00 2001 From: WadydX <65117428+WadydX@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:51:08 +0100 Subject: [PATCH 1/2] fix(cli): add --overwrite to drive download and slides thumbnail Adds --overwrite flags to drive download and slides thumbnail --out, matching the existing photos download behavior. Also covers drive download --tab via the shared docs tab export path. Closes #827 --- internal/cmd/docs_tab_export.go | 24 +++++++++++++++--------- internal/cmd/drive_download.go | 29 ++++++++++++++++++----------- internal/cmd/export_via_drive.go | 2 +- internal/cmd/slides_thumbnail.go | 12 +++++++++--- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/internal/cmd/docs_tab_export.go b/internal/cmd/docs_tab_export.go index 726ad8cce..ffccfbe53 100644 --- a/internal/cmd/docs_tab_export.go +++ b/internal/cmd/docs_tab_export.go @@ -66,10 +66,11 @@ func isGoogleAuthHost(host string) bool { } type tabExportParams struct { - DocID string - OutFlag string - Format string - TabQuery string + DocID string + OutFlag string + Format string + TabQuery string + Overwrite bool } // sanitizeFilenameComponent replaces characters unsafe for filenames with @@ -181,10 +182,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams) } if dryErr := dryRunExit(ctx, flags, "docs.tab-export", map[string]any{ - "docID": p.DocID, - "tab": p.TabQuery, - "format": format, - "out": outPath, + "docID": p.DocID, + "tab": p.TabQuery, + "format": format, + "out": outPath, + "overwrite": p.Overwrite, }); dryErr != nil { return dryErr } @@ -234,7 +236,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams) return copyErr } - f, outPath, writeErr := createUserOutputFile(outPath) + f, outPath, writeErr := openUserOutputFile(outPath, outputFileOptions{ + Overwrite: p.Overwrite, + FileMode: 0o600, + DirMode: 0o700, + }) if writeErr != nil { return writeErr } diff --git a/internal/cmd/drive_download.go b/internal/cmd/drive_download.go index 46a82a82a..f59214987 100644 --- a/internal/cmd/drive_download.go +++ b/internal/cmd/drive_download.go @@ -17,10 +17,11 @@ import ( ) type DriveDownloadCmd struct { - FileID string `arg:"" name:"fileId" help:"File ID"` - Output OutputPathFlag `embed:""` - Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"` - Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"` + FileID string `arg:"" name:"fileId" help:"File ID"` + Output OutputPathFlag `embed:""` + Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"` + Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"` + Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"` } func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -36,10 +37,11 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { } } return runDocsTabExport(ctx, flags, tabExportParams{ - DocID: fileID, - OutFlag: c.Output.Path, - Format: c.Format, - TabQuery: tab, + DocID: fileID, + OutFlag: c.Output.Path, + Format: c.Format, + TabQuery: tab, + Overwrite: c.Overwrite, }) } @@ -72,6 +74,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { "out": outPathFlag, "default_downloads_dir": defaultDir, "format": strings.ToLower(strings.TrimSpace(c.Format)), + "overwrite": c.Overwrite, }); dryRunErr != nil { return dryRunErr } @@ -106,7 +109,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format) + downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format, c.Overwrite) if err != nil { return err } @@ -126,7 +129,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } -func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) { +func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string, overwrite bool) (string, int64, error) { isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.") normalizedFormat := strings.ToLower(strings.TrimSpace(format)) if normalizedFormat == formatAuto { @@ -182,7 +185,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File return stdoutPath, n, copyErr } - f, outPath, err := createUserOutputFile(outPath) + f, outPath, err := openUserOutputFile(outPath, outputFileOptions{ + Overwrite: overwrite, + FileMode: 0o600, + DirMode: 0o700, + }) if err != nil { return "", 0, err } diff --git a/internal/cmd/export_via_drive.go b/internal/cmd/export_via_drive.go index e12a26214..c5f856759 100644 --- a/internal/cmd/export_via_drive.go +++ b/internal/cmd/export_via_drive.go @@ -107,7 +107,7 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp return usage("can't combine --json with --out -") } - downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format) + downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format, false) if err != nil { return err } diff --git a/internal/cmd/slides_thumbnail.go b/internal/cmd/slides_thumbnail.go index 6635245f4..9fc101033 100644 --- a/internal/cmd/slides_thumbnail.go +++ b/internal/cmd/slides_thumbnail.go @@ -18,6 +18,7 @@ type SlidesThumbnailCmd struct { Size string `name:"size" help:"Thumbnail size: small|medium|large" default:"large"` Format string `name:"format" help:"Thumbnail format: png|jpeg" default:"png"` Output string `name:"out" aliases:"output" help:"Write the thumbnail image to a local file"` + Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"` } func (c *SlidesThumbnailCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -53,6 +54,7 @@ func (c *SlidesThumbnailCmd) Run(ctx context.Context, flags *RootFlags) error { "size": strings.ToLower(size), "format": strings.ToLower(format), "out": outputPath, + "overwrite": c.Overwrite, }); dryRunErr != nil { return dryRunErr } @@ -90,7 +92,7 @@ func (c *SlidesThumbnailCmd) Run(ctx context.Context, flags *RootFlags) error { } if outputPath != "" { - written, writtenPath, err := downloadSlidesThumbnail(ctx, thumb.ContentUrl, outputPath) + written, writtenPath, err := downloadSlidesThumbnail(ctx, thumb.ContentUrl, outputPath, c.Overwrite) if err != nil { return err } @@ -148,7 +150,7 @@ func normalizeSlidesThumbnailFormat(v string) (string, error) { } } -func downloadSlidesThumbnail(ctx context.Context, url, outputPath string) (int64, string, error) { +func downloadSlidesThumbnail(ctx context.Context, url, outputPath string, overwrite bool) (int64, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return 0, "", fmt.Errorf("build thumbnail download request: %w", err) @@ -164,7 +166,11 @@ func downloadSlidesThumbnail(ctx context.Context, url, outputPath string) (int64 return 0, "", fmt.Errorf("download thumbnail: unexpected status %s", resp.Status) } - f, expandedPath, err := createUserOutputFile(outputPath) + f, expandedPath, err := openUserOutputFile(outputPath, outputFileOptions{ + Overwrite: overwrite, + FileMode: 0o600, + DirMode: 0o700, + }) if err != nil { return 0, "", fmt.Errorf("create output file: %w", err) } From 83e206a1dc9dae8de93be770ac72525ab01e3790 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Jun 2026 00:52:40 -0400 Subject: [PATCH 2/2] fix(cli): require overwrite opt-in for downloads Co-authored-by: WadydX <65117428+WadydX@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/commands/gog-docs-export.md | 1 + docs/commands/gog-download.md | 1 + docs/commands/gog-drive-download.md | 1 + docs/commands/gog-sheets-export.md | 1 + docs/commands/gog-slides-export.md | 1 + docs/commands/gog-slides-thumbnail.md | 1 + internal/cmd/docs.go | 20 ++++---- internal/cmd/docs_tab_export_test.go | 46 +++++++++++++++++-- internal/cmd/drive_download_test.go | 44 ++++++++++++++++-- internal/cmd/drive_export_format_test.go | 2 +- internal/cmd/drive_validation_more_test.go | 4 +- internal/cmd/execute_export_via_drive_test.go | 21 ++++++++- internal/cmd/export_via_drive.go | 5 +- internal/cmd/sheets.go | 3 +- internal/cmd/slides.go | 3 +- internal/cmd/slides_thumbnail_test.go | 14 +++++- 17 files changed, 142 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349d23964..30aa88d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Calendar: report multi-calendar event truncation on stderr for text output and as per-calendar page tokens in JSON. (#831) — thanks @TurboTheTurtle. +- Downloads: protect Drive downloads, Docs/Sheets/Slides exports, Docs tab exports, and Slides thumbnails from replacing existing files unless `--overwrite` is passed. (#827, #829) — thanks @WadydX. - Docs: update the Docker authentication example to persist file-keyring tokens with `GOG_HOME`. (#828, #830) — thanks @WadydX. ## 0.28.0 - 2026-06-15 diff --git a/docs/commands/gog-docs-export.md b/docs/commands/gog-docs-export.md index 2d3409c50..c139e8859 100644 --- a/docs/commands/gog-docs-export.md +++ b/docs/commands/gog-docs-export.md @@ -34,6 +34,7 @@ gog docs (doc) export (download,dl) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Output file path (default: gogcli config dir) | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-download.md b/docs/commands/gog-download.md index 9e5f3e2c7..e601e2b6f 100644 --- a/docs/commands/gog-download.md +++ b/docs/commands/gog-download.md @@ -34,6 +34,7 @@ gog download (dl) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Output file path (default: gogcli config dir) | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-drive-download.md b/docs/commands/gog-drive-download.md index b93aca574..aed8a9551 100644 --- a/docs/commands/gog-drive-download.md +++ b/docs/commands/gog-drive-download.md @@ -34,6 +34,7 @@ gog drive (drv) download [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Output file path (default: gogcli config dir) | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-sheets-export.md b/docs/commands/gog-sheets-export.md index 7ad02ccd3..a27a23ca6 100644 --- a/docs/commands/gog-sheets-export.md +++ b/docs/commands/gog-sheets-export.md @@ -34,6 +34,7 @@ gog sheets (sheet) export (download,dl) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Output file path (default: gogcli config dir) | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-slides-export.md b/docs/commands/gog-slides-export.md index 6f849f9b4..e722af9b8 100644 --- a/docs/commands/gog-slides-export.md +++ b/docs/commands/gog-slides-export.md @@ -34,6 +34,7 @@ gog slides (slide) export (download,dl) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Output file path (default: gogcli config dir) | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-slides-thumbnail.md b/docs/commands/gog-slides-thumbnail.md index e58f59f13..ba5e30ad6 100644 --- a/docs/commands/gog-slides-thumbnail.md +++ b/docs/commands/gog-slides-thumbnail.md @@ -34,6 +34,7 @@ gog slides (slide) thumbnail (thumb) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--out`
`--output` | `string` | | Write the thumbnail image to a local file | +| `--overwrite` | `bool` | | Overwrite an existing output file | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index a33088a5e..604540a09 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -152,19 +152,21 @@ func projectRawDocumentTab(doc *docs.Document, tab *docs.Tab) (*docs.Document, e } type DocsExportCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Output OutputPathFlag `embed:""` - Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"` - Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"` + DocID string `arg:"" name:"docId" help:"Doc ID"` + Output OutputPathFlag `embed:""` + Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"` + Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"` + Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"` } func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error { if tab := strings.TrimSpace(c.Tab); tab != "" { return runDocsTabExport(ctx, flags, tabExportParams{ - DocID: c.DocID, - OutFlag: c.Output.Path, - Format: c.Format, - TabQuery: tab, + DocID: c.DocID, + OutFlag: c.Output.Path, + Format: c.Format, + TabQuery: tab, + Overwrite: c.Overwrite, }) } return exportViaDrive(ctx, flags, exportViaDriveOptions{ @@ -172,7 +174,7 @@ func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error { ExpectedMime: "application/vnd.google-apps.document", KindLabel: "Google Doc", DefaultFormat: "pdf", - }, c.DocID, c.Output.Path, c.Format) + }, c.DocID, c.Output.Path, c.Format, c.Overwrite) } type DocsInfoCmd struct { diff --git a/internal/cmd/docs_tab_export_test.go b/internal/cmd/docs_tab_export_test.go index d779ba333..b29d21f00 100644 --- a/internal/cmd/docs_tab_export_test.go +++ b/internal/cmd/docs_tab_export_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -413,6 +414,39 @@ func TestRunDocsTabExport_JSONOutput(t *testing.T) { } } +func TestRunDocsTabExport_RequiresOverwrite(t *testing.T) { + ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write([]byte("replacement")) + }), false) + + outPath := filepath.Join(t.TempDir(), "output.pdf") + if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + params := tabExportParams{ + DocID: "doc1", + OutFlag: outPath, + Format: "pdf", + TabQuery: "First Tab", + } + + if err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, params); !errors.Is(err, os.ErrExist) { + t.Fatalf("expected existing-file error, got %v", err) + } + if got, err := os.ReadFile(outPath); err != nil || string(got) != "original" { + t.Fatalf("existing file changed: data=%q err=%v", got, err) + } + + params.Overwrite = true + if err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, params); err != nil { + t.Fatalf("runDocsTabExport overwrite: %v", err) + } + if got, err := os.ReadFile(outPath); err != nil || string(got) != "replacement" { + t.Fatalf("overwrite failed: data=%q err=%v", got, err) + } +} + func TestDocsExportCmd_TabRouting(t *testing.T) { ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/pdf") @@ -420,12 +454,16 @@ func TestDocsExportCmd_TabRouting(t *testing.T) { }), false) outPath := filepath.Join(t.TempDir(), "out.pdf") + if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } cmd := &DocsExportCmd{ - DocID: "doc1", - Format: "pdf", - Tab: "Second Tab", - Output: OutputPathFlag{Path: outPath}, + DocID: "doc1", + Format: "pdf", + Tab: "Second Tab", + Output: OutputPathFlag{Path: outPath}, + Overwrite: true, } if err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"}); err != nil { diff --git a/internal/cmd/drive_download_test.go b/internal/cmd/drive_download_test.go index 12ed4a8a2..522800019 100644 --- a/internal/cmd/drive_download_test.go +++ b/internal/cmd/drive_download_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -14,6 +15,39 @@ import ( "google.golang.org/api/option" ) +func TestDownloadDriveFile_RequiresOverwrite(t *testing.T) { + download := func(context.Context, *drive.Service, string) (*http.Response, error) { + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("replacement")), + }, nil + } + ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil) + dest := filepath.Join(t.TempDir(), "file.bin") + if err := os.WriteFile(dest, []byte("original"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false); !errors.Is(err, os.ErrExist) { + t.Fatalf("expected existing-file error, got %v", err) + } + if got, err := os.ReadFile(dest); err != nil || string(got) != "original" { + t.Fatalf("existing file changed: data=%q err=%v", got, err) + } + + outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", true) + if err != nil { + t.Fatalf("downloadDriveFile overwrite: %v", err) + } + if outPath != dest || size != int64(len("replacement")) { + t.Fatalf("outPath=%q size=%d", outPath, size) + } + if got, err := os.ReadFile(dest); err != nil || string(got) != "replacement" { + t.Fatalf("overwrite failed: data=%q err=%v", got, err) + } +} + func TestDownloadDriveFile_NonGoogleDoc(t *testing.T) { body := "hello" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -39,7 +73,7 @@ func TestDownloadDriveFile_NonGoogleDoc(t *testing.T) { tmp := t.TempDir() dest := filepath.Join(tmp, "file.bin") ctx := withDriveTestOperations(context.Background(), svc, driveDownload, driveExportDownload) - outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "") + outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false) if err != nil { t.Fatalf("downloadDriveFile: %v", err) } @@ -71,7 +105,7 @@ func TestDownloadDriveFile_NonGoogleDocFormatRejected(t *testing.T) { dest := filepath.Join(t.TempDir(), "file.html") ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil) - _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "html") + _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "html", false) if err == nil { t.Fatalf("expected error") } @@ -111,7 +145,7 @@ func TestDownloadDriveFile_GoogleDocExport(t *testing.T) { tmp := t.TempDir() dest := filepath.Join(tmp, "doc.txt") ctx := withDriveTestOperations(context.Background(), svc, driveDownload, driveExportDownload) - outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/vnd.google-apps.document"}, dest, "") + outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/vnd.google-apps.document"}, dest, "", false) if err != nil { t.Fatalf("downloadDriveFile: %v", err) } @@ -142,7 +176,7 @@ func TestDownloadDriveFile_HTTPError(t *testing.T) { tmp := t.TempDir() dest := filepath.Join(tmp, "file.bin") ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil) - _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "") + _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false) if err == nil { t.Fatalf("expected error") } @@ -163,7 +197,7 @@ func TestDownloadDriveFile_CreatesMissingParentDirs(t *testing.T) { tmp := t.TempDir() dest := filepath.Join(tmp, "no-such-dir", "file.bin") ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil) - outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "") + outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false) if err != nil { t.Fatalf("downloadDriveFile: %v", err) } diff --git a/internal/cmd/drive_export_format_test.go b/internal/cmd/drive_export_format_test.go index b07dcca2a..63aa45787 100644 --- a/internal/cmd/drive_export_format_test.go +++ b/internal/cmd/drive_export_format_test.go @@ -220,7 +220,7 @@ func TestDownloadDriveFile_InvalidExportFormat(t *testing.T) { _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{ Id: "id1", MimeType: "application/vnd.google-apps.document", - }, dest, "xlsx") + }, dest, "xlsx", false) if err == nil { t.Fatalf("expected error") } diff --git a/internal/cmd/drive_validation_more_test.go b/internal/cmd/drive_validation_more_test.go index 099966246..0e3d1d676 100644 --- a/internal/cmd/drive_validation_more_test.go +++ b/internal/cmd/drive_validation_more_test.go @@ -476,10 +476,10 @@ func TestDownloadDriveFile_ErrorPaths(t *testing.T) { } ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, export) - if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: "text/plain"}, "out", ""); err == nil { + if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: "text/plain"}, "out", "", false); err == nil { t.Fatalf("expected download error") } - if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: driveMimeGoogleDoc}, "out", ""); err == nil { + if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: driveMimeGoogleDoc}, "out", "", false); err == nil { t.Fatalf("expected export error") } } diff --git a/internal/cmd/execute_export_via_drive_test.go b/internal/cmd/execute_export_via_drive_test.go index b117df681..e7d7af5b6 100644 --- a/internal/cmd/execute_export_via_drive_test.go +++ b/internal/cmd/execute_export_via_drive_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -50,6 +51,9 @@ func TestExecute_DocsExport_JSON(t *testing.T) { } outBase := filepath.Join(t.TempDir(), "out") + if writeErr := os.WriteFile(outBase+".docx", []byte("original"), 0o600); writeErr != nil { + t.Fatalf("WriteFile: %v", writeErr) + } result := executeWithDriveTestOperations(t, []string{ "--json", @@ -58,8 +62,23 @@ func TestExecute_DocsExport_JSON(t *testing.T) { "--out", outBase, "--format", "docx", }, svc, nil, export) + if !errors.Is(result.err, os.ErrExist) { + t.Fatalf("expected existing-file error, got %v", result.err) + } + if got, readErr := os.ReadFile(outBase + ".docx"); readErr != nil || string(got) != "original" { + t.Fatalf("existing file changed: data=%q err=%v", got, readErr) + } + + result = executeWithDriveTestOperations(t, []string{ + "--json", + "--account", "a@b.com", + "docs", "export", "id1", + "--out", outBase, + "--format", "docx", + "--overwrite", + }, svc, nil, export) if result.err != nil { - t.Fatalf("Execute: %v", result.err) + t.Fatalf("Execute overwrite: %v", result.err) } var parsed struct { diff --git a/internal/cmd/export_via_drive.go b/internal/cmd/export_via_drive.go index c5f856759..7d077cb5c 100644 --- a/internal/cmd/export_via_drive.go +++ b/internal/cmd/export_via_drive.go @@ -22,7 +22,7 @@ type exportViaDriveOptions struct { const defaultExportFormat = "pdf" -func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOptions, id string, outPathFlag string, format string) error { +func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOptions, id string, outPathFlag string, format string, overwrite bool) error { u := ui.FromContext(ctx) argName := strings.TrimSpace(opts.ArgName) @@ -69,6 +69,7 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp "out": outPathFlag, "default_downloads_dir": defaultDownloadsDir, "format": format, + "overwrite": overwrite, "expected_mime": strings.TrimSpace(opts.ExpectedMime), "kind": strings.TrimSpace(opts.KindLabel), }); err != nil { @@ -107,7 +108,7 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp return usage("can't combine --json with --out -") } - downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format, false) + downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format, overwrite) if err != nil { return err } diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 50ffcbde2..1859d4978 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -71,6 +71,7 @@ type SheetsExportCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` Output OutputPathFlag `embed:""` Format string `name:"format" help:"Export format: pdf|xlsx|csv" default:"xlsx"` + Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"` } func (c *SheetsExportCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -81,7 +82,7 @@ func (c *SheetsExportCmd) Run(ctx context.Context, flags *RootFlags) error { KindLabel: "Google Sheet", DefaultFormat: "xlsx", FormatHelp: "Export format: pdf|xlsx|csv", - }, c.SpreadsheetID, c.Output.Path, c.Format) + }, c.SpreadsheetID, c.Output.Path, c.Format, c.Overwrite) } type SheetsCopyCmd struct { diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index 2965f5ad9..4449d8256 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -77,6 +77,7 @@ type SlidesExportCmd struct { PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` Output OutputPathFlag `embed:""` Format string `name:"format" help:"Export format: pdf|pptx" default:"pptx"` + Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"` } func (c *SlidesExportCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -85,7 +86,7 @@ func (c *SlidesExportCmd) Run(ctx context.Context, flags *RootFlags) error { ExpectedMime: "application/vnd.google-apps.presentation", KindLabel: "Google Slides presentation", DefaultFormat: "pptx", - }, c.PresentationID, c.Output.Path, c.Format) + }, c.PresentationID, c.Output.Path, c.Format, c.Overwrite) } type SlidesInfoCmd struct { diff --git a/internal/cmd/slides_thumbnail_test.go b/internal/cmd/slides_thumbnail_test.go index 705c5357a..cdc864615 100644 --- a/internal/cmd/slides_thumbnail_test.go +++ b/internal/cmd/slides_thumbnail_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -172,6 +173,9 @@ func TestSlidesThumbnail_Download(t *testing.T) { flags := &RootFlags{Account: "a@b.com"} outputPath := filepath.Join(t.TempDir(), "slide.png") + if err := os.WriteFile(outputPath, []byte("original"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } var out bytes.Buffer ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, &out, io.Discard), svc) cmd := &SlidesThumbnailCmd{ @@ -179,8 +183,16 @@ func TestSlidesThumbnail_Download(t *testing.T) { SlideID: "slide_1", Output: outputPath, } + if err := cmd.Run(ctx, flags); !errors.Is(err, os.ErrExist) { + t.Fatalf("expected existing-file error, got %v", err) + } + if gotBytes, err := os.ReadFile(outputPath); err != nil || string(gotBytes) != "original" { + t.Fatalf("existing file changed: data=%q err=%v", gotBytes, err) + } + + cmd.Overwrite = true if err := cmd.Run(ctx, flags); err != nil { - t.Fatalf("Run: %v", err) + t.Fatalf("Run overwrite: %v", err) } gotBytes, err := os.ReadFile(outputPath)