package cmd import ( "bytes" "context" "errors" "fmt" "io" "net/http" "os" "strings" "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" gapi "google.golang.org/api/googleapi" "github.com/steipete/gogcli/internal/googleapi" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) var newDocsService = googleapi.NewDocs type DocsCmd struct { Export DocsExportCmd `cmd:"" name:"export" help:"Export a Google Doc (pdf|docx|txt)"` Info DocsInfoCmd `cmd:"" name:"info" help:"Get Google Doc metadata"` Create DocsCreateCmd `cmd:"" name:"create" help:"Create a Google Doc"` Copy DocsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Doc"` Cat DocsCatCmd `cmd:"" name:"cat" help:"Print a Google Doc as plain text"` } type DocsExportCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Output OutputPathFlag `embed:""` Format string `name:"format" help:"Export format: pdf|docx|txt" default:"pdf"` } func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error { return exportViaDrive(ctx, flags, exportViaDriveOptions{ ArgName: "docId", ExpectedMime: "application/vnd.google-apps.document", KindLabel: "Google Doc", DefaultFormat: "pdf", }, c.DocID, c.Output.Path, c.Format) } type DocsInfoCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` } func (c *DocsInfoCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) account, err := requireAccount(flags) if err != nil { return err } id := strings.TrimSpace(c.DocID) if id == "" { return usage("empty docId") } svc, err := newDocsService(ctx, account) if err != nil { return err } doc, err := svc.Documents.Get(id). Fields("documentId,title,revisionId"). Context(ctx). Do() if err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) } return err } if doc == nil { return errors.New("doc not found") } file := map[string]any{ "id": doc.DocumentId, "name": doc.Title, "mimeType": driveMimeGoogleDoc, } if link := docsWebViewLink(doc.DocumentId); link != "" { file["webViewLink"] = link } if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{ strFile: file, "document": doc, }) } u.Out().Printf("id\t%s", doc.DocumentId) u.Out().Printf("name\t%s", doc.Title) u.Out().Printf("mime\t%s", driveMimeGoogleDoc) if link := docsWebViewLink(doc.DocumentId); link != "" { u.Out().Printf("link\t%s", link) } if doc.RevisionId != "" { u.Out().Printf("revision\t%s", doc.RevisionId) } return nil } type DocsCreateCmd struct { Title string `arg:"" name:"title" help:"Doc title"` Parent string `name:"parent" help:"Destination folder ID"` } func (c *DocsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) account, err := requireAccount(flags) if err != nil { return err } title := strings.TrimSpace(c.Title) if title == "" { return usage("empty title") } svc, err := newDriveService(ctx, account) if err != nil { return err } f := &drive.File{ Name: title, MimeType: "application/vnd.google-apps.document", } parent := strings.TrimSpace(c.Parent) if parent != "" { f.Parents = []string{parent} } created, err := svc.Files.Create(f). SupportsAllDrives(true). Fields("id, name, mimeType, webViewLink"). Context(ctx). Do() if err != nil { return err } if created == nil { return errors.New("create failed") } if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created}) } u.Out().Printf("id\t%s", created.Id) u.Out().Printf("name\t%s", created.Name) u.Out().Printf("mime\t%s", created.MimeType) if created.WebViewLink != "" { u.Out().Printf("link\t%s", created.WebViewLink) } return nil } type DocsCopyCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Title string `arg:"" name:"title" help:"New title"` Parent string `name:"parent" help:"Destination folder ID"` } func (c *DocsCopyCmd) Run(ctx context.Context, flags *RootFlags) error { return copyViaDrive(ctx, flags, copyViaDriveOptions{ ArgName: "docId", ExpectedMime: "application/vnd.google-apps.document", KindLabel: "Google Doc", }, c.DocID, c.Title, c.Parent) } type DocsCatCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` MaxBytes int64 `name:"max-bytes" help:"Max bytes to read (0 = unlimited)" default:"2000000"` } func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error { account, err := requireAccount(flags) if err != nil { return err } id := strings.TrimSpace(c.DocID) if id == "" { return usage("empty docId") } svc, err := newDocsService(ctx, account) if err != nil { return err } doc, err := svc.Documents.Get(id). Context(ctx). Do() if err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) } return err } if doc == nil { return errors.New("doc not found") } text := docsPlainText(doc, c.MaxBytes) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{"text": text}) } _, err = io.WriteString(os.Stdout, text) return err } func docsWebViewLink(id string) string { id = strings.TrimSpace(id) if id == "" { return "" } return "https://docs.google.com/document/d/" + id + "/edit" } func docsPlainText(doc *docs.Document, maxBytes int64) string { if doc == nil || doc.Body == nil { return "" } var buf bytes.Buffer for _, el := range doc.Body.Content { if !appendDocsElementText(&buf, maxBytes, el) { break } } return buf.String() } func appendDocsElementText(buf *bytes.Buffer, maxBytes int64, el *docs.StructuralElement) bool { if el == nil { return true } switch { case el.Paragraph != nil: for _, p := range el.Paragraph.Elements { if p.TextRun == nil { continue } if !appendLimited(buf, maxBytes, p.TextRun.Content) { return false } } case el.Table != nil: for rowIdx, row := range el.Table.TableRows { if rowIdx > 0 { if !appendLimited(buf, maxBytes, "\n") { return false } } for cellIdx, cell := range row.TableCells { if cellIdx > 0 { if !appendLimited(buf, maxBytes, "\t") { return false } } for _, content := range cell.Content { if !appendDocsElementText(buf, maxBytes, content) { return false } } } } case el.TableOfContents != nil: for _, content := range el.TableOfContents.Content { if !appendDocsElementText(buf, maxBytes, content) { return false } } } return true } func appendLimited(buf *bytes.Buffer, maxBytes int64, s string) bool { if maxBytes <= 0 { _, _ = buf.WriteString(s) return true } remaining := int(maxBytes) - buf.Len() if remaining <= 0 { return false } if len(s) > remaining { _, _ = buf.WriteString(s[:remaining]) return false } _, _ = buf.WriteString(s) return true } func isDocsNotFound(err error) bool { var apiErr *gapi.Error if !errors.As(err, &apiErr) { return false } return apiErr.Code == http.StatusNotFound }