* 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>
317 lines
7.0 KiB
Go
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
|
|
}
|