fix(drive): support stdout downloads
This commit is contained in:
parent
e30d870b3f
commit
8e4f5acebb
@ -18,6 +18,7 @@
|
||||
|
||||
### Fixed
|
||||
- Calendar: display `calendar events` times and JSON local fields in the calendar timezone instead of preserving arbitrary event offsets. (#493)
|
||||
- Drive/Docs/Sheets/Slides: treat `--out -` as stdout for downloads and exports instead of creating `-`/`-.ext` files; reject `--json --out -` to keep byte streams parseable. (#286)
|
||||
- Docs: deprecate editing-command `--tab-id` in favor of `--tab`, and resolve tab titles to canonical tab IDs before mutations. (#533) — thanks @johnbenjaminlewis.
|
||||
- Docs: convert Markdown formatting for `docs write --append --markdown` instead of appending raw Markdown syntax. (#530, #272) — thanks @eric-x-liu.
|
||||
- CLI: show direct Google Cloud API enablement links and matching `auth add --services ...` hints when Google returns API-not-enabled errors.
|
||||
|
||||
@ -597,6 +597,7 @@ Some open source Google CLIs ship a pre-configured OAuth client ID/secret copied
|
||||
Flag aliases:
|
||||
- `--out` also accepts `--output`.
|
||||
- `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads).
|
||||
- Drive download/export commands accept `--out -` to write file bytes to stdout; do not combine this with `--json`.
|
||||
|
||||
### Authentication
|
||||
|
||||
@ -1002,6 +1003,7 @@ gog drive download <fileId> --format pdf --out ./exported.pdf # Google Works
|
||||
gog drive download <fileId> --format docx --out ./doc.docx
|
||||
gog drive download <fileId> --format md --out ./note.md # Google Doc → Markdown
|
||||
gog drive download <fileId> --format pptx --out ./slides.pptx
|
||||
gog drive download <fileId> --out - > downloaded.bin
|
||||
|
||||
# Organize
|
||||
gog drive mkdir "New Folder"
|
||||
@ -1034,6 +1036,7 @@ gog docs create "My Doc" --file ./doc.md # Import markdown
|
||||
gog docs create "My Doc" --pageless
|
||||
gog docs copy <docId> "My Doc Copy"
|
||||
gog docs export <docId> --format pdf --out ./doc.pdf
|
||||
gog docs export <docId> --format txt --out - > doc.txt
|
||||
gog docs list-tabs <docId>
|
||||
gog docs cat <docId> --tab "Notes"
|
||||
gog docs cat <docId> --all-tabs
|
||||
@ -1418,6 +1421,7 @@ gog docs export <docId> --format docx --out ./doc.docx
|
||||
gog docs export <docId> --format txt --out ./doc.txt
|
||||
gog docs export <docId> --format md --out ./doc.md
|
||||
gog docs export <docId> --format html --out ./doc.html
|
||||
gog docs export <docId> --format txt --out - > doc.txt
|
||||
|
||||
# Sed-style regex editing with Markdown formatting (sedmat)
|
||||
gog docs sed <docId> 's/pattern/replacement/g'
|
||||
|
||||
@ -26,9 +26,11 @@ Each service command is a thin wrapper:
|
||||
- Type guard: compare `mimeType` and error with `file is not a <KindLabel> (mimeType="...")`.
|
||||
- `--out` defaults to `$(os.UserConfigDir())/gogcli/drive-downloads/` (via `internal/config:EnsureDriveDownloadsDir`).
|
||||
- `--out` can be dir or explicit file path (via `internal/cmd/drive_download_helpers.go:resolveDriveDownloadDestPath`).
|
||||
- `--out -` writes export bytes to stdout; JSON mode rejects it to avoid mixing metadata with bytes.
|
||||
- Output
|
||||
- `--json`: `{ "path": "...", "size": <bytes> }`
|
||||
- text: `path\t...` / `size\t...`
|
||||
- `--out -`: raw bytes only, no path/size status on stdout
|
||||
|
||||
## Add a new export command
|
||||
|
||||
|
||||
@ -158,6 +158,7 @@ Environment:
|
||||
Flag aliases:
|
||||
- `--out` also accepts `--output`.
|
||||
- `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads).
|
||||
- Drive download/export commands accept `--out -` to write file bytes to stdout; `--json --out -` is rejected.
|
||||
|
||||
## Commands (current + planned)
|
||||
|
||||
@ -189,7 +190,7 @@ Flag aliases:
|
||||
- `gog drive ls [--all] [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives]` (`--all` and `--parent` are mutually exclusive)
|
||||
- `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 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; large non-JSON uploads print progress to stderr)
|
||||
- `gog drive mkdir <name> [--parent ID]`
|
||||
- `gog drive delete <fileId> [--permanent]`
|
||||
|
||||
@ -197,6 +197,9 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
|
||||
if err != nil {
|
||||
@ -209,6 +212,9 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Out().Printf("path\t%s", downloadedPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
@ -936,7 +942,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File
|
||||
return "", 0, mimeErr
|
||||
}
|
||||
}
|
||||
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
|
||||
if isStdoutPath(destPath) {
|
||||
outPath = stdoutPath
|
||||
} else {
|
||||
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
|
||||
}
|
||||
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
|
||||
} else {
|
||||
outPath = destPath
|
||||
@ -952,6 +962,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File
|
||||
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if isStdoutPath(outPath) {
|
||||
n, copyErr := io.Copy(os.Stdout, resp.Body)
|
||||
return stdoutPath, n, copyErr
|
||||
}
|
||||
|
||||
f, outPath, err := createUserOutputFile(outPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
|
||||
@ -24,6 +24,9 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string,
|
||||
}
|
||||
|
||||
destPath := strings.TrimSpace(outPathFlag)
|
||||
if isStdoutPath(destPath) {
|
||||
return stdoutPath, nil
|
||||
}
|
||||
// Expand ~ to home directory (shell doesn't expand when path is quoted).
|
||||
if destPath != "" {
|
||||
expanded, err := config.ExpandPath(destPath)
|
||||
|
||||
175
internal/cmd/drive_stdout_test.go
Normal file
175
internal/cmd/drive_stdout_test.go
Normal file
@ -0,0 +1,175 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
)
|
||||
|
||||
func TestExecute_DriveDownload_WithOutStdout(t *testing.T) {
|
||||
origDownload := driveDownload
|
||||
t.Cleanup(func() { driveDownload = origDownload })
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
svc, closeSvc := newDriveMetadataTestService(t, "text/plain")
|
||||
t.Cleanup(closeSvc)
|
||||
stubDriveServiceForTest(t, svc)
|
||||
|
||||
driveDownload = func(context.Context, *drive.Service, string) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader("abc")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
stdout := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if execErr := Execute([]string{
|
||||
"--account", "a@b.com",
|
||||
"drive", "download", "id1",
|
||||
"--out", "-",
|
||||
}); execErr != nil {
|
||||
t.Fatalf("Execute: %v", execErr)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if stdout != "abc" {
|
||||
t.Fatalf("stdout=%q, want raw bytes", stdout)
|
||||
}
|
||||
if _, statErr := os.Stat("-"); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected no file named -, stat=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DriveDownload_WithOutStdout_JSONRejected(t *testing.T) {
|
||||
origDownload := driveDownload
|
||||
t.Cleanup(func() { driveDownload = origDownload })
|
||||
|
||||
svc, closeSvc := newDriveMetadataTestService(t, "text/plain")
|
||||
t.Cleanup(closeSvc)
|
||||
stubDriveServiceForTest(t, svc)
|
||||
|
||||
called := false
|
||||
driveDownload = func(context.Context, *drive.Service, string) (*http.Response, error) {
|
||||
called = true
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader("abc")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var execErr error
|
||||
stdout := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
execErr = Execute([]string{
|
||||
"--json",
|
||||
"--account", "a@b.com",
|
||||
"drive", "download", "id1",
|
||||
"--out", "-",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if execErr == nil || !strings.Contains(execErr.Error(), "can't combine --json with --out -") {
|
||||
t.Fatalf("unexpected error: %v", execErr)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Fatalf("stdout=%q, want empty", stdout)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("download should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DocsExport_WithOutStdout(t *testing.T) {
|
||||
origExport := driveExportDownload
|
||||
t.Cleanup(func() { driveExportDownload = origExport })
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
svc, closeSvc := newDriveMetadataTestService(t, "application/vnd.google-apps.document")
|
||||
t.Cleanup(closeSvc)
|
||||
stubDriveServiceForTest(t, svc)
|
||||
|
||||
var gotExportMime string
|
||||
driveExportDownload = func(_ context.Context, _ *drive.Service, _ string, mimeType string) (*http.Response, error) {
|
||||
gotExportMime = mimeType
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader("plain text\n")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
stdout := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if execErr := Execute([]string{
|
||||
"--account", "a@b.com",
|
||||
"docs", "export", "id1",
|
||||
"--out", "-",
|
||||
"--format", "txt",
|
||||
}); execErr != nil {
|
||||
t.Fatalf("Execute: %v", execErr)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if stdout != "plain text\n" {
|
||||
t.Fatalf("stdout=%q, want raw export bytes", stdout)
|
||||
}
|
||||
if gotExportMime != "text/plain" {
|
||||
t.Fatalf("unexpected export mime type: %q", gotExportMime)
|
||||
}
|
||||
if _, statErr := os.Stat("-.txt"); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected no file named -.txt, stat=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DocsExport_WithOutStdout_JSONRejected(t *testing.T) {
|
||||
origExport := driveExportDownload
|
||||
t.Cleanup(func() { driveExportDownload = origExport })
|
||||
|
||||
svc, closeSvc := newDriveMetadataTestService(t, "application/vnd.google-apps.document")
|
||||
t.Cleanup(closeSvc)
|
||||
stubDriveServiceForTest(t, svc)
|
||||
|
||||
called := false
|
||||
driveExportDownload = func(context.Context, *drive.Service, string, string) (*http.Response, error) {
|
||||
called = true
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader("plain text\n")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var execErr error
|
||||
stdout := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
execErr = Execute([]string{
|
||||
"--json",
|
||||
"--account", "a@b.com",
|
||||
"docs", "export", "id1",
|
||||
"--out", "-",
|
||||
"--format", "txt",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if execErr == nil || !strings.Contains(execErr.Error(), "can't combine --json with --out -") {
|
||||
t.Fatalf("unexpected error: %v", execErr)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Fatalf("stdout=%q, want empty", stdout)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("export should not be called")
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
@ -38,6 +40,23 @@ func stubDriveServiceForTest(t *testing.T, svc *drive.Service) {
|
||||
newDriveService = stubDriveService(svc)
|
||||
}
|
||||
|
||||
func newDriveMetadataTestService(t *testing.T, mimeType string) (*drive.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
return newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/id1") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "id1",
|
||||
"name": "Doc",
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func requireQuery(t *testing.T, r *http.Request, key, want string) {
|
||||
t.Helper()
|
||||
if got := r.URL.Query().Get(key); got != want {
|
||||
|
||||
@ -102,6 +102,9 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format)
|
||||
if err != nil {
|
||||
@ -111,6 +114,9 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"path": downloadedPath, "size": size})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
u.Out().Printf("path\t%s", downloadedPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
return nil
|
||||
|
||||
@ -9,12 +9,18 @@ import (
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
)
|
||||
|
||||
const stdoutPath = "-"
|
||||
|
||||
type outputFileOptions struct {
|
||||
Overwrite bool
|
||||
FileMode os.FileMode
|
||||
DirMode os.FileMode
|
||||
}
|
||||
|
||||
func isStdoutPath(path string) bool {
|
||||
return strings.TrimSpace(path) == stdoutPath
|
||||
}
|
||||
|
||||
func openUserOutputFile(path string, opts outputFileOptions) (*os.File, string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user