gogcli/internal/cmd/docs.go
salmonumbrella 07ffcb5d84
fix(paths): expand ~ in user-provided file paths (#56)
* fix(paths): expand ~ in user-provided file paths

When users specify paths with ~ (e.g., --out ~/Downloads/file.pdf) and
the path is quoted in the shell command, the tilde is not expanded by
the shell. This caused files to be written to a literal ~/Downloads
directory instead of the user's home directory.

Add config.ExpandPath() function that expands ~ at the beginning of
paths to the user's home directory. Apply this fix to all user-provided
file paths across:

- gmail attachment download (--out)
- drive download/export (--out)
- drive upload (localPath argument)
- auth token export (--out)
- auth credentials/import/keep (input paths)
- gmail thread attachments (--out-dir)
- gmail send/drafts (--attach)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): address wrapcheck and wsl issues

* fix(calendar): support ISO 8601 time format and add 'list' alias

- Add parsing for ISO 8601 datetime with numeric timezone without colon
  (e.g., 2026-01-09T16:38:41-0800), which is the format produced by
  macOS `date +%Y-%m-%dT%H:%M:%S%z`
- Add 'list' as an alias for 'events' subcommand for more intuitive CLI
  usage (gog calendar list instead of gog calendar events)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(changelog): note PR #56

* chore(lint): dedupe file string

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-10 02:19:30 +00:00

317 lines
7.0 KiB
Go

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
}