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:
parent
9362a7664e
commit
26e01cfa97
@ -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",
|
||||
|
||||
245
internal/cmd/docs_tab_export.go
Normal file
245
internal/cmd/docs_tab_export.go
Normal 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)
|
||||
}
|
||||
480
internal/cmd/docs_tab_export_test.go
Normal file
480
internal/cmd/docs_tab_export_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user