From 26e01cfa972a6193d757bf05f153c93e46631036 Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Sun, 26 Apr 2026 18:22:24 -0400 Subject: [PATCH] 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) --- internal/cmd/docs.go | 11 +- internal/cmd/docs_tab_export.go | 245 ++++++++++++++ internal/cmd/docs_tab_export_test.go | 480 +++++++++++++++++++++++++++ internal/cmd/drive.go | 20 +- 4 files changed, 753 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/docs_tab_export.go create mode 100644 internal/cmd/docs_tab_export_test.go diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index e76d33e..05441b5 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -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", diff --git a/internal/cmd/docs_tab_export.go b/internal/cmd/docs_tab_export.go new file mode 100644 index 0000000..411289f --- /dev/null +++ b/internal/cmd/docs_tab_export.go @@ -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) +} diff --git a/internal/cmd/docs_tab_export_test.go b/internal/cmd/docs_tab_export_test.go new file mode 100644 index 0000000..a5e21f1 --- /dev/null +++ b/internal/cmd/docs_tab_export_test.go @@ -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("Sign in")) + })) + + 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) + } +} diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 7aeb52b..d307a46 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -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 }