Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
384f241
fix(drive): validate download formats
salmonumbrella Jan 31, 2026
27db96e
feat(docs): add write and update commands
salmonumbrella Jan 31, 2026
0ad9966
fix: address linter issues in docs/drive commands
salmonumbrella Jan 31, 2026
067ae55
fix(drive): include shared drives in search
salmonumbrella Jan 31, 2026
c22c16d
test(drive): cover invalid download format
salmonumbrella Jan 31, 2026
2643619
test(docs): add --file input tests for write and update commands
salmonumbrella Jan 31, 2026
f1f68cc
test(docs): add invalid index validation tests for DocsUpdateCmd
salmonumbrella Jan 31, 2026
8275632
fix(docs): clarify update command help text
salmonumbrella Jan 31, 2026
fc13e75
fix(calendar): expand date-only --to
salmonumbrella Feb 1, 2026
9d4b13f
fix(drive): include all drives in ls
salmonumbrella Feb 1, 2026
f57031e
test(calendar): add unit tests for isDayExpr function
salmonumbrella Feb 1, 2026
fee4957
test(calendar): add tests for --to tomorrow and --to now
salmonumbrella Feb 1, 2026
863da27
test(calendar): add test for --to monday end-of-day expansion
salmonumbrella Feb 1, 2026
d2ac294
docs(drive): add comment explaining shared drives query params
salmonumbrella Feb 1, 2026
460ac8d
fix(gmail): avoid double quoted-printable decode
salmonumbrella Feb 2, 2026
cc5cd16
test(gmail): add unit tests for QP encoding helpers
salmonumbrella Feb 2, 2026
2e5a43d
test(gmail): add test for QP-encoded equals sign
salmonumbrella Feb 2, 2026
3e999df
docs(gmail): add comments explaining QP decoding heuristics
salmonumbrella Feb 2, 2026
b44ade7
fix(lint): resolve goconst and shadow lint errors
salmonumbrella Feb 2, 2026
897a12c
fix(drive): add --convert to upload
salmonumbrella Feb 2, 2026
a4693f1
fix(lint): resolve shadow error in drive upload convert
salmonumbrella Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@ gog drive copy <fileId> "Copy Name"

# Upload and download
gog drive upload ./path/to/file --parent <folderId>
gog drive upload ./path/to/report.docx --convert
gog drive download <fileId> --out ./downloaded.bin
gog drive download <fileId> --format pdf --out ./exported.pdf
gog drive download <fileId> --format docx --out ./doc.docx
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Flag aliases:
- `gog drive search <text> [--max N] [--page TOKEN]`
- `gog drive get <fileId>`
- `gog drive download <fileId> [--out PATH]`
- `gog drive upload <localPath> [--name N] [--parent ID]`
- `gog drive upload <localPath> [--name N] [--parent ID] [--convert]`
- `gog drive mkdir <name> [--parent ID]`
- `gog drive delete <fileId>`
- `gog drive move <fileId> --parent ID`
Expand Down
266 changes: 266 additions & 0 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import (
"os"
"strings"

"github.com/alecthomas/kong"
"google.golang.org/api/docs/v1"
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"

"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
Expand All @@ -26,6 +28,8 @@ type DocsCmd struct {
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"`
Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"`
Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"`
Cat DocsCatCmd `cmd:"" name:"cat" help:"Print a Google Doc as plain text"`
}

Expand Down Expand Up @@ -177,6 +181,214 @@ func (c *DocsCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
}, c.DocID, c.Title, c.Parent)
}

type DocsWriteCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to write"`
File string `name:"file" help:"Text file path ('-' for stdin)"`
Append bool `name:"append" help:"Append instead of replacing the document body"`
}

func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.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")
}

text, provided, err := resolveTextInput(c.Text, c.File, kctx, "text", "file")
if err != nil {
return err
}
if !provided {
return usage("required: --text or --file")
}
if text == "" {
return usage("empty text")
}

svc, err := newDocsService(ctx, account)
if err != nil {
return err
}

doc, err := svc.Documents.Get(id).
Fields("documentId,body/content(startIndex,endIndex)").
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")
}

endIndex := docsDocumentEndIndex(doc)
insertIndex := int64(1)
if c.Append {
insertIndex = docsAppendIndex(endIndex)
}

reqs := []*docs.Request{}
if !c.Append {
deleteEnd := endIndex - 1
if deleteEnd > 1 {
reqs = append(reqs, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: 1,
EndIndex: deleteEnd,
},
},
})
}
}

reqs = append(reqs, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIndex},
Text: text,
},
})

resp, err := svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: reqs}).
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 outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": resp.DocumentId,
"requests": len(reqs),
"append": c.Append,
"index": insertIndex,
}
if resp.WriteControl != nil {
payload["writeControl"] = resp.WriteControl
}
return outfmt.WriteJSON(os.Stdout, payload)
}

u.Out().Printf("id\t%s", resp.DocumentId)
u.Out().Printf("requests\t%d", len(reqs))
u.Out().Printf("append\t%t", c.Append)
u.Out().Printf("index\t%d", insertIndex)
if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" {
u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId)
}
return nil
}

type DocsUpdateCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to insert"`
File string `name:"file" help:"Text file path ('-' for stdin)"`
Index int64 `name:"index" help:"Insert index (default: end of document)"`
}

func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.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")
}

text, provided, err := resolveTextInput(c.Text, c.File, kctx, "text", "file")
if err != nil {
return err
}
if !provided {
return usage("required: --text or --file")
}
if text == "" {
return usage("empty text")
}

if flagProvided(kctx, "index") && c.Index <= 0 {
return usage("invalid --index (must be >= 1)")
}

svc, err := newDocsService(ctx, account)
if err != nil {
return err
}

insertIndex := c.Index
if insertIndex <= 0 {
var doc *docs.Document
doc, err = svc.Documents.Get(id).
Fields("documentId,body/content(startIndex,endIndex)").
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")
}
insertIndex = docsAppendIndex(docsDocumentEndIndex(doc))
}

reqs := []*docs.Request{
{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIndex},
Text: text,
},
},
}

resp, err := svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: reqs}).
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 outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": resp.DocumentId,
"requests": len(reqs),
"index": insertIndex,
}
if resp.WriteControl != nil {
payload["writeControl"] = resp.WriteControl
}
return outfmt.WriteJSON(os.Stdout, payload)
}

u.Out().Printf("id\t%s", resp.DocumentId)
u.Out().Printf("requests\t%d", len(reqs))
u.Out().Printf("index\t%d", insertIndex)
if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" {
u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId)
}
return nil
}

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"`
Expand Down Expand Up @@ -307,6 +519,60 @@ func appendLimited(buf *bytes.Buffer, maxBytes int64, s string) bool {
return true
}

func resolveTextInput(text, file string, kctx *kong.Context, textFlag, fileFlag string) (string, bool, error) {
file = strings.TrimSpace(file)
textProvided := text != "" || flagProvided(kctx, textFlag)
fileProvided := file != "" || flagProvided(kctx, fileFlag)
if textProvided && fileProvided {
return "", true, usage(fmt.Sprintf("use only one of --%s or --%s", textFlag, fileFlag))
}
if fileProvided {
b, err := readTextInput(file)
if err != nil {
return "", true, err
}
return string(b), true, nil
}
if textProvided {
return text, true, nil
}
return text, false, nil
}

func readTextInput(path string) ([]byte, error) {
if path == "-" {
return io.ReadAll(os.Stdin)
}
expanded, err := config.ExpandPath(path)
if err != nil {
return nil, err
}
return os.ReadFile(expanded) //nolint:gosec // user-provided path
}

func docsDocumentEndIndex(doc *docs.Document) int64 {
if doc == nil || doc.Body == nil {
return 1
}
end := int64(1)
for _, el := range doc.Body.Content {
if el == nil {
continue
}
if el.EndIndex > end {
end = el.EndIndex
}
}
return end
}

func docsAppendIndex(endIndex int64) int64 {
if endIndex > 1 {
return endIndex - 1
}
return 1
}

func isDocsNotFound(err error) bool {
var apiErr *gapi.Error
if !errors.As(err, &apiErr) {
Expand Down
Loading