feat(cli): add --tab flag for per-tab Google Doc export

Add experimental --tab flag to `gog docs export` and `gog drive download`
that exports a single tab from a Google Doc by title or ID. Uses the
undocumented per-tab Docs export endpoint with a custom redirect policy
that rejects non-Google redirects to detect auth failures early.

(cherry picked from commit 5a741be7ce0df13f75a10bc7a70df9fbab9475da)
This commit is contained in:
Ben Lewis 2026-04-26 18:22:24 -04:00 committed by Peter Steinberger
parent 9362a7664e
commit 26e01cfa97
No known key found for this signature in database
4 changed files with 753 additions and 3 deletions

View File

@ -18,7 +18,7 @@ import (
var newDocsService = googleapi.NewDocs
type DocsCmd struct {
Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt|md)"`
Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt|md|html)"`
Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"`
Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"`
Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"`
@ -40,9 +40,18 @@ 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')"`
}
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,
})
}
return exportViaDrive(ctx, flags, exportViaDriveOptions{
ArgName: "docId",
ExpectedMime: "application/vnd.google-apps.document",

View File

@ -0,0 +1,245 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
// UNSTABLE: docsTabExportBaseURL is the base for the undocumented per-tab
// export endpoint. If Google changes or removes it, callers will see an HTTP
// 302 redirect to a login page or an HTTP 404.
const docsTabExportBaseURL = "https://docs.google.com/document/d"
// maxRedirects matches net/http.defaultCheckRedirect (10 hops).
const maxRedirects = 10
// newDocsHTTPClient is swapped in tests to avoid real auth.
var newDocsHTTPClient = func(ctx context.Context, email string) (*http.Client, error) {
return googleapi.NewHTTPClient(ctx, googleauth.ServiceDocs, email)
}
// googleExportRedirectPolicy allows redirects within Google's serving
// infrastructure (*.google.com, *.googleusercontent.com, *.googleapis.com)
// but rejects redirects to unrecognised domains, which typically indicate
// an auth-wall redirect.
func googleExportRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return errors.New("too many redirects")
}
if len(via) > 0 && !isGoogleHost(req.URL.Host) {
return fmt.Errorf("refusing redirect from %s to non-Google host %s (possible auth redirect; try re-authenticating)", via[0].URL.Host, req.URL.Host)
}
return nil
}
func isGoogleHost(host string) bool {
for _, suffix := range []string{".google.com", ".googleusercontent.com", ".googleapis.com"} {
if host == suffix[1:] || strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
type tabExportParams struct {
DocID string
OutFlag string
Format string
TabQuery string
}
// sanitizeFilenameComponent replaces characters unsafe for filenames with
// underscores, collapsing runs. Only ASCII word chars ([0-9A-Za-z_]), dots,
// @, and hyphens are kept; non-ASCII characters are replaced.
var unsafeFilenameChars = regexp.MustCompile(`[^\w.@-]+`)
func sanitizeFilenameComponent(s string) string {
return unsafeFilenameChars.ReplaceAllString(s, "_")
}
const formatHTML = "html"
func tabExportFormatParam(format string) (string, error) {
switch format {
case "pdf", "docx", "txt", formatHTML:
return format, nil
case "md":
return "markdown", nil
default:
return "", fmt.Errorf("--tab export does not support format %q (supported: pdf|docx|txt|md|"+formatHTML+")", format)
}
}
func docsTabExportURL(docID, format, tabID string) string {
v := url.Values{}
v.Set("format", format)
v.Set("tab", tabID)
return fmt.Sprintf("%s/%s/export?%s", docsTabExportBaseURL, url.PathEscape(docID), v.Encode())
}
func resolveTabID(ctx context.Context, docsSvc *docs.Service, docID, tabQuery string) (string, error) {
doc, err := docsSvc.Documents.Get(docID).
Fields("tabs(tabProperties(tabId,title,index),childTabs)").
Context(ctx).
Do()
if err != nil {
if isDocsNotFound(err) {
return "", fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
}
return "", fmt.Errorf("resolve tab: %w", err)
}
tabs := flattenTabs(doc.Tabs)
tab, err := findTab(tabs, tabQuery)
if err != nil {
return "", err
}
return tab.TabProperties.TabId, nil
}
func tabExportOutPath(outFlag, docID, tabQuery, format string) (string, error) {
defaultBase := docID + "_" + sanitizeFilenameComponent(tabQuery) + "." + format
outPath := strings.TrimSpace(outFlag)
if outPath != "" {
expanded, err := config.ExpandPath(outPath)
if err != nil {
return "", err
}
if st, statErr := os.Stat(expanded); statErr == nil && st.IsDir() {
return filepath.Join(expanded, defaultBase), nil
}
return expanded, nil
}
dir, err := config.EnsureDriveDownloadsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultBase), nil
}
// runDocsTabExport performs a per-tab export using the undocumented Docs export
// URL. It resolves the tab, downloads the content, and writes it to a file.
func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams) error {
u := ui.FromContext(ctx)
p.DocID = normalizeGoogleID(strings.TrimSpace(p.DocID))
if p.DocID == "" {
return usage("empty docId")
}
account, err := requireAccount(flags)
if err != nil {
return err
}
format := p.Format
if format == "" || format == formatAuto {
format = "pdf"
}
formatParam, fmtErr := tabExportFormatParam(format)
if fmtErr != nil {
return fmtErr
}
outPath, pathErr := tabExportOutPath(p.OutFlag, p.DocID, p.TabQuery, format)
if pathErr != nil {
return pathErr
}
if dryErr := dryRunExit(ctx, flags, "docs.tab-export", map[string]any{
"docID": p.DocID,
"tab": p.TabQuery,
"format": format,
"out": outPath,
}); dryErr != nil {
return dryErr
}
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
u.Err().Printf("Resolving tab %q…", p.TabQuery)
tabID, err := resolveTabID(ctx, docsSvc, p.DocID, p.TabQuery)
if err != nil {
return err
}
httpClient, err := newDocsHTTPClient(ctx, account)
if err != nil {
return err
}
httpClient.CheckRedirect = googleExportRedirectPolicy
u.Err().Printf("Exporting tab %q as %s…", p.TabQuery, format)
exportURL := docsTabExportURL(p.DocID, formatParam, tabID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, exportURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if checkErr := checkTabExportResponse(resp, format); checkErr != nil {
return checkErr
}
f, outPath, writeErr := createUserOutputFile(outPath)
if writeErr != nil {
return writeErr
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"path": outPath, "size": n})
}
u.Out().Printf("path\t%s", outPath)
u.Out().Printf("size\t%s", formatDriveSize(n))
return nil
}
func checkTabExportResponse(resp *http.Response, format string) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if ct := resp.Header.Get("Content-Type"); format != formatHTML && strings.HasPrefix(ct, "text/html") {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("tab export returned unexpected text/html (possible auth redirect; try 'gog auth login'): %s", strings.TrimSpace(string(snippet)))
}
return nil
}
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
body := strings.TrimSpace(string(snippet))
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("tab export failed: %s: %s (check sharing settings or re-authenticate with 'gog auth login')", resp.Status, body)
}
return fmt.Errorf("tab export failed: %s: %s (undocumented endpoint may have changed)", resp.Status, body)
}

View File

@ -0,0 +1,480 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"google.golang.org/api/docs/v1"
"google.golang.org/api/option"
)
func tabExportDocResponse() map[string]any {
return map[string]any{
"documentId": "doc1",
"title": "Multi-Tab Doc",
"tabs": []any{
map[string]any{
"tabProperties": map[string]any{"tabId": "t.abc", "title": "First Tab", "index": 0},
},
map[string]any{
"tabProperties": map[string]any{"tabId": "t.def", "title": "Second Tab", "index": 1},
},
},
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func stubTabExportDeps(t *testing.T, exportHandler http.Handler) {
t.Helper()
origDocs := newDocsService
origHTTP := newDocsHTTPClient
t.Cleanup(func() {
newDocsService = origDocs
newDocsHTTPClient = origHTTP
})
docsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tabExportDocResponse())
}))
t.Cleanup(docsSrv.Close)
docSvc, err := docs.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(docsSrv.Client()),
option.WithEndpoint(docsSrv.URL+"/"),
)
if err != nil {
t.Fatalf("NewDocsService: %v", err)
}
newDocsService = func(_ context.Context, _ string) (*docs.Service, error) {
return docSvc, nil
}
newDocsHTTPClient = func(_ context.Context, _ string) (*http.Client, error) {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
exportHandler.ServeHTTP(rec, req)
return rec.Result(), nil
}),
}, nil
}
}
func TestTabExportFormatParam(t *testing.T) {
tests := []struct {
format string
want string
wantErr bool
}{
{"pdf", "pdf", false},
{"docx", "docx", false},
{"txt", "txt", false},
{"md", "markdown", false},
{"html", "html", false},
{"csv", "", true},
{"xlsx", "", true},
}
for _, tt := range tests {
got, err := tabExportFormatParam(tt.format)
if tt.wantErr {
if err == nil {
t.Errorf("tabExportFormatParam(%q): expected error", tt.format)
}
continue
}
if err != nil {
t.Errorf("tabExportFormatParam(%q): %v", tt.format, err)
} else if got != tt.want {
t.Errorf("tabExportFormatParam(%q) = %q, want %q", tt.format, got, tt.want)
}
}
}
func TestGoogleExportRedirectPolicy(t *testing.T) {
ctx := context.Background()
origReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://docs.google.com/export", nil)
tests := []struct {
name string
target string
wantErr string
}{
{"same host", "https://docs.google.com/other", ""},
{"googleusercontent", "https://doc-04-0k-docstext.googleusercontent.com/export/abc", ""},
{"googleapis", "https://storage.googleapis.com/bucket/file", ""},
{"non-google host", "https://evil.example.com/steal", "non-Google host"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, tt.target, nil)
err := googleExportRedirectPolicy(req, []*http.Request{origReq})
if tt.wantErr == "" {
if err != nil {
t.Errorf("expected success, got: %v", err)
}
} else {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("expected error containing %q, got: %v", tt.wantErr, err)
}
}
})
}
via := make([]*http.Request, 10)
for i := range via {
via[i], _ = http.NewRequestWithContext(ctx, http.MethodGet, "https://docs.google.com/r", nil)
}
nextReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://docs.google.com/r", nil)
err := googleExportRedirectPolicy(nextReq, via)
if err == nil || !strings.Contains(err.Error(), "too many redirects") {
t.Errorf("expected too many redirects error, got: %v", err)
}
}
func TestDocsTabExportURL(t *testing.T) {
got := docsTabExportURL("DOC123", "pdf", "t.abc")
want := "https://docs.google.com/document/d/DOC123/export?format=pdf&tab=t.abc"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSanitizeFilenameComponent(t *testing.T) {
tests := []struct {
in, want string
}{
{"t.abc", "t.abc"},
{"My Budget Sheet", "My_Budget_Sheet"},
{"tab/with\\bad:chars", "tab_with_bad_chars"},
}
for _, tt := range tests {
if got := sanitizeFilenameComponent(tt.in); got != tt.want {
t.Errorf("sanitizeFilenameComponent(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestResolveTabID(t *testing.T) {
docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tabExportDocResponse())
}))
defer cleanup()
tests := []struct {
query string
wantID string
wantErr string
}{
{"Second Tab", "t.def", ""},
{"t.abc", "t.abc", ""},
{"Nonexistent", "", "tab not found"},
}
for _, tt := range tests {
tabID, err := resolveTabID(context.Background(), docSvc, "doc1", tt.query)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("resolveTabID(%q): got err=%v, want containing %q", tt.query, err, tt.wantErr)
}
continue
}
if err != nil {
t.Errorf("resolveTabID(%q): %v", tt.query, err)
} else if tabID != tt.wantID {
t.Errorf("resolveTabID(%q) = %q, want %q", tt.query, tabID, tt.wantID)
}
}
}
func TestRunDocsTabExport(t *testing.T) {
tests := []struct {
name string
format string
tabQuery string
respCT string
respBody string
wantBody string
wantErr string
}{
{
name: "pdf success",
format: "pdf",
tabQuery: "First Tab",
respCT: "application/pdf",
respBody: "exported PDF content",
wantBody: "exported PDF content",
},
{
name: "markdown format",
format: "md",
tabQuery: "t.abc",
respCT: "text/markdown",
respBody: "# Markdown",
wantBody: "# Markdown",
},
{
name: "unsupported format",
format: "csv",
tabQuery: "t.abc",
wantErr: "does not support format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == "" {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", tt.respCT)
_, _ = w.Write([]byte(tt.respBody))
}))
}
outPath := filepath.Join(t.TempDir(), "output."+tt.format)
ctx := newDocsCmdContext(t)
flags := &RootFlags{Account: "test@example.com"}
err := runDocsTabExport(ctx, flags, tabExportParams{
DocID: "doc1",
OutFlag: outPath,
Format: tt.format,
TabQuery: tt.tabQuery,
})
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("runDocsTabExport: %v", err)
}
if tt.wantBody != "" {
data, readErr := os.ReadFile(outPath)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(data) != tt.wantBody {
t.Errorf("output = %q, want %q", string(data), tt.wantBody)
}
}
})
}
}
func TestRunDocsTabExport_HTMLRedirectGuard(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<html>Sign in</html>"))
}))
ctx := newDocsCmdContext(t)
err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, tabExportParams{
DocID: "doc1",
OutFlag: filepath.Join(t.TempDir(), "out.pdf"),
Format: "pdf",
TabQuery: "First Tab",
})
if err == nil || !strings.Contains(err.Error(), "unexpected text/html") {
t.Fatalf("expected HTML redirect error, got: %v", err)
}
}
func TestRunDocsTabExport_HTTPErrors(t *testing.T) {
tests := []struct {
name string
status int
wantErr string
}{
{"forbidden", http.StatusForbidden, "tab export failed"},
{"unauthorized", http.StatusUnauthorized, "re-authenticate"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte("error body"))
}))
ctx := newDocsCmdContext(t)
err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, tabExportParams{
DocID: "doc1",
OutFlag: filepath.Join(t.TempDir(), "out.pdf"),
Format: "pdf",
TabQuery: "First Tab",
})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
func TestRunDocsTabExport_JSONOutput(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("pdf bytes"))
}))
outPath := filepath.Join(t.TempDir(), "output.pdf")
ctx := newDocsJSONContext(t)
raw := captureStdout(t, func() {
err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, tabExportParams{
DocID: "doc1",
OutFlag: outPath,
Format: "pdf",
TabQuery: "First Tab",
})
if err != nil {
t.Fatalf("runDocsTabExport: %v", err)
}
})
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
t.Fatalf("json decode: %v (raw=%q)", err, raw)
}
if _, ok := result["path"]; !ok {
t.Errorf("JSON output missing 'path' key: %v", result)
}
}
func TestDocsExportCmd_TabRouting(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("tab pdf"))
}))
outPath := filepath.Join(t.TempDir(), "out.pdf")
ctx := newDocsCmdContext(t)
cmd := &DocsExportCmd{
DocID: "doc1",
Format: "pdf",
Tab: "Second Tab",
Output: OutputPathFlag{Path: outPath},
}
if err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"}); err != nil {
t.Fatalf("DocsExportCmd.Run: %v", err)
}
data, _ := os.ReadFile(outPath)
if string(data) != "tab pdf" {
t.Errorf("output = %q, want %q", string(data), "tab pdf")
}
}
func TestDocsExportCmd_TabEmptyDocID(t *testing.T) {
ctx := newDocsCmdContext(t)
cmd := &DocsExportCmd{DocID: "", Tab: "some-tab"}
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err == nil || !strings.Contains(err.Error(), "empty docId") {
t.Fatalf("expected empty docId error, got: %v", err)
}
}
func TestTabExportOutPath(t *testing.T) {
tests := []struct {
name string
outFlag string
docID string
tabQuery string
format string
wantBase string
}{
{"tab ID in filename", "", "doc123", "t.abc", "pdf", "doc123_t.abc.pdf"},
{"markdown extension", "", "doc1", "t.xyz", "md", "doc1_t.xyz.md"},
{"tab title sanitized", "", "doc1", "My Budget Sheet", "pdf", "doc1_My_Budget_Sheet.pdf"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
path, err := tabExportOutPath(tt.outFlag, tt.docID, tt.tabQuery, tt.format)
if err != nil {
t.Fatalf("tabExportOutPath: %v", err)
}
if got := filepath.Base(path); got != tt.wantBase {
t.Errorf("base = %q, want %q", got, tt.wantBase)
}
})
}
}
func TestTabExportOutPath_ExplicitPath(t *testing.T) {
outPath := filepath.Join(t.TempDir(), "custom.pdf")
path, err := tabExportOutPath(outPath, "doc1", "t.abc", "pdf")
if err != nil {
t.Fatalf("tabExportOutPath: %v", err)
}
if path != outPath {
t.Errorf("got %q, want %q", path, outPath)
}
}
func TestTabExportOutPath_DirectoryOutput(t *testing.T) {
tmpDir := t.TempDir()
path, err := tabExportOutPath(tmpDir, "doc1", "t.abc", "pdf")
if err != nil {
t.Fatalf("tabExportOutPath: %v", err)
}
if filepath.Dir(path) != tmpDir {
t.Errorf("expected file in %q, got %q", tmpDir, filepath.Dir(path))
}
}
func TestDriveDownloadCmd_TabRouting(t *testing.T) {
tests := []struct {
name string
format string
}{
{"explicit format", "pdf"},
{"defaults to pdf", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("tab pdf"))
}))
ctx := newDocsCmdContext(t)
cmd := &DriveDownloadCmd{
FileID: "doc1",
Tab: "First Tab",
Format: tt.format,
Output: OutputPathFlag{Path: filepath.Join(t.TempDir(), "out.pdf")},
}
if err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"}); err != nil {
t.Fatalf("DriveDownloadCmd.Run: %v", err)
}
})
}
}
func TestDriveDownloadCmd_TabUnsupportedFormat(t *testing.T) {
ctx := newDocsCmdContext(t)
cmd := &DriveDownloadCmd{
FileID: "doc1",
Tab: "First Tab",
Format: "csv",
Output: OutputPathFlag{Path: filepath.Join(t.TempDir(), "out.csv")},
}
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err == nil || !strings.Contains(err.Error(), "--tab limits export formats") {
t.Fatalf("expected --tab format restriction error, got: %v", err)
}
}

View File

@ -156,19 +156,35 @@ 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')"`
}
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
if fileID == "" {
return usage("empty fileId")
}
if tab := strings.TrimSpace(c.Tab); tab != "" {
if f := c.Format; f != "" && f != formatAuto {
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
}
}
return runDocsTabExport(ctx, flags, tabExportParams{
DocID: fileID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
})
}
u := ui.FromContext(ctx)
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
return formatErr
}