1259 lines
31 KiB
Go
1259 lines
31 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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"
|
|
)
|
|
|
|
var newDocsService = googleapi.NewDocs
|
|
|
|
type DocsCmd struct {
|
|
Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt)"`
|
|
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"`
|
|
Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"`
|
|
Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"`
|
|
ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"`
|
|
Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"`
|
|
Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"`
|
|
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
|
|
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"`
|
|
Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"`
|
|
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
|
|
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
|
|
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
|
|
Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"`
|
|
}
|
|
|
|
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(ctx, 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"`
|
|
File string `name:"file" help:"Markdown file to import" type:"existingfile"`
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
driveSvc, 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}
|
|
}
|
|
|
|
createCall := driveSvc.Files.Create(f).
|
|
SupportsAllDrives(true).
|
|
Fields("id, name, mimeType, webViewLink")
|
|
|
|
// When --file is set, upload the markdown content and let Drive convert it.
|
|
var images []markdownImage
|
|
if c.File != "" {
|
|
raw, readErr := os.ReadFile(c.File)
|
|
if readErr != nil {
|
|
return fmt.Errorf("read markdown file: %w", readErr)
|
|
}
|
|
content := string(raw)
|
|
|
|
var cleaned string
|
|
cleaned, images = extractMarkdownImages(content)
|
|
|
|
createCall = createCall.Media(
|
|
strings.NewReader(cleaned),
|
|
gapi.ContentType("text/markdown"),
|
|
)
|
|
}
|
|
|
|
created, err := createCall.Context(ctx).Do()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if created == nil {
|
|
return errors.New("create failed")
|
|
}
|
|
|
|
// Pass 2: insert images if any were found.
|
|
if len(images) > 0 {
|
|
if err := c.insertImages(ctx, account, driveSvc, created.Id, images); err != nil {
|
|
return fmt.Errorf("insert images: %w", err)
|
|
}
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, 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
|
|
}
|
|
|
|
// insertImages performs pass 2: reads back the created doc, resolves image URLs,
|
|
// and replaces placeholder text with inline images.
|
|
func (c *DocsCreateCmd) insertImages(ctx context.Context, account string, driveSvc *drive.Service, docID string, images []markdownImage) error {
|
|
docsSvc, err := newDocsService(ctx, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Read back the document to find placeholder positions.
|
|
doc, err := docsSvc.Documents.Get(docID).Context(ctx).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("read back document: %w", err)
|
|
}
|
|
|
|
placeholders := findPlaceholderIndices(doc, len(images))
|
|
if len(placeholders) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Resolve image URLs — upload local files to Drive temporarily.
|
|
imageURLs := make(map[int]string)
|
|
var tempFileIDs []string
|
|
defer cleanupDriveFileIDsBestEffort(ctx, driveSvc, tempFileIDs)
|
|
|
|
for _, img := range images {
|
|
if _, ok := placeholders[img.placeholder()]; !ok {
|
|
continue
|
|
}
|
|
if img.isRemote() {
|
|
imageURLs[img.index] = img.originalRef
|
|
continue
|
|
}
|
|
|
|
realPath, resolveErr := resolveMarkdownImagePath(c.File, img.originalRef)
|
|
if resolveErr != nil {
|
|
return resolveErr
|
|
}
|
|
|
|
url, fileID, uploadErr := uploadLocalImage(ctx, driveSvc, realPath)
|
|
if uploadErr != nil {
|
|
return uploadErr
|
|
}
|
|
tempFileIDs = append(tempFileIDs, fileID)
|
|
imageURLs[img.index] = url
|
|
}
|
|
|
|
reqs := buildImageInsertRequests(placeholders, images, imageURLs)
|
|
if len(reqs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err = docsSvc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
|
Requests: reqs,
|
|
}).Context(ctx).Do()
|
|
return err
|
|
}
|
|
|
|
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 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(ctx, 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(ctx, 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"`
|
|
Tab string `name:"tab" help:"Tab title or ID to read (omit for default behavior)"`
|
|
AllTabs bool `name:"all-tabs" help:"Show all tabs with headers"`
|
|
Raw bool `name:"raw" help:"Output the raw Google Docs API JSON response without modifications"`
|
|
Numbered bool `name:"numbered" short:"N" help:"Prefix each paragraph with its number"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// --raw: dump the full Google Docs API response as JSON.
|
|
if c.Raw {
|
|
call := svc.Documents.Get(id).Context(ctx)
|
|
if c.Tab != "" || c.AllTabs {
|
|
call = call.IncludeTabsContent(true)
|
|
}
|
|
doc, rawErr := call.Do()
|
|
if rawErr != nil {
|
|
if isDocsNotFound(rawErr) {
|
|
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
|
|
}
|
|
return rawErr
|
|
}
|
|
raw, rawErr := doc.MarshalJSON()
|
|
if rawErr != nil {
|
|
return fmt.Errorf("marshalling raw response: %w", rawErr)
|
|
}
|
|
var buf bytes.Buffer
|
|
if indentErr := json.Indent(&buf, raw, "", " "); indentErr != nil {
|
|
_, werr := os.Stdout.Write(raw)
|
|
return werr
|
|
}
|
|
buf.WriteByte('\n')
|
|
_, rawErr = buf.WriteTo(os.Stdout)
|
|
return rawErr
|
|
}
|
|
|
|
// Use tabs API when --tab or --all-tabs is specified.
|
|
if c.Tab != "" || c.AllTabs {
|
|
return c.runWithTabs(ctx, svc, id)
|
|
}
|
|
|
|
// Default: original behavior (no tabs API).
|
|
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")
|
|
}
|
|
|
|
if c.Numbered {
|
|
return c.printNumbered(ctx, doc, "")
|
|
}
|
|
|
|
text := docsPlainText(doc, c.MaxBytes)
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"text": text})
|
|
}
|
|
_, err = io.WriteString(os.Stdout, text)
|
|
return err
|
|
}
|
|
|
|
func (c *DocsCatCmd) runWithTabs(ctx context.Context, svc *docs.Service, id string) error {
|
|
doc, err := svc.Documents.Get(id).
|
|
IncludeTabsContent(true).
|
|
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")
|
|
}
|
|
|
|
tabs := flattenTabs(doc.Tabs)
|
|
|
|
if c.Tab != "" {
|
|
tab := findTab(tabs, c.Tab)
|
|
if tab == nil {
|
|
return fmt.Errorf("tab not found: %s", c.Tab)
|
|
}
|
|
if c.Numbered {
|
|
return c.printNumbered(ctx, doc, c.Tab)
|
|
}
|
|
text := tabPlainText(tab, c.MaxBytes)
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
|
"tab": tabJSON(tab, text),
|
|
})
|
|
}
|
|
_, err = io.WriteString(os.Stdout, text)
|
|
return err
|
|
}
|
|
|
|
// --all-tabs
|
|
if outfmt.IsJSON(ctx) {
|
|
var out []map[string]any
|
|
for _, tab := range tabs {
|
|
text := tabPlainText(tab, c.MaxBytes)
|
|
out = append(out, tabJSON(tab, text))
|
|
}
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"tabs": out})
|
|
}
|
|
|
|
for i, tab := range tabs {
|
|
title := tabTitle(tab)
|
|
if i > 0 {
|
|
if _, err := fmt.Fprintln(os.Stdout); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(os.Stdout, "=== Tab: %s ===\n", title); err != nil {
|
|
return err
|
|
}
|
|
text := tabPlainText(tab, c.MaxBytes)
|
|
if _, err := io.WriteString(os.Stdout, text); err != nil {
|
|
return err
|
|
}
|
|
if text != "" && !strings.HasSuffix(text, "\n") {
|
|
if _, err := fmt.Fprintln(os.Stdout); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type DocsListTabsCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
}
|
|
|
|
func (c *DocsListTabsCmd) 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).
|
|
IncludeTabsContent(true).
|
|
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")
|
|
}
|
|
|
|
tabs := flattenTabs(doc.Tabs)
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
var out []map[string]any
|
|
for _, tab := range tabs {
|
|
out = append(out, tabInfoJSON(tab))
|
|
}
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"tabs": out})
|
|
}
|
|
|
|
u.Out().Printf("ID\tTITLE\tINDEX")
|
|
for _, tab := range tabs {
|
|
if tab.TabProperties != nil {
|
|
u.Out().Printf("%s\t%s\t%d",
|
|
tab.TabProperties.TabId,
|
|
tab.TabProperties.Title,
|
|
tab.TabProperties.Index,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Write / Insert / Delete / Find-Replace commands ---
|
|
|
|
type DocsInsertCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
Content string `arg:"" optional:"" name:"content" help:"Text to insert (or use --file / stdin)"`
|
|
Index int64 `name:"index" help:"Character index to insert at (1 = beginning)" default:"1"`
|
|
File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"`
|
|
}
|
|
|
|
func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
u := ui.FromContext(ctx)
|
|
account, err := requireAccount(flags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
docID := strings.TrimSpace(c.DocID)
|
|
if docID == "" {
|
|
return usage("empty docId")
|
|
}
|
|
|
|
content, err := resolveContentInput(c.Content, c.File)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if content == "" {
|
|
return usage("no content provided (use argument, --file, or stdin)")
|
|
}
|
|
|
|
if c.Index < 1 {
|
|
return usage("--index must be >= 1 (index 0 is reserved)")
|
|
}
|
|
|
|
svc, err := newDocsService(ctx, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
|
Requests: []*docs.Request{{
|
|
InsertText: &docs.InsertTextRequest{
|
|
Text: content,
|
|
Location: &docs.Location{
|
|
Index: c.Index,
|
|
},
|
|
},
|
|
}},
|
|
}).Context(ctx).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("inserting text: %w", err)
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
|
"documentId": result.DocumentId,
|
|
"inserted": len(content),
|
|
"atIndex": c.Index,
|
|
})
|
|
}
|
|
|
|
u.Out().Printf("documentId\t%s", result.DocumentId)
|
|
u.Out().Printf("inserted\t%d bytes", len(content))
|
|
u.Out().Printf("atIndex\t%d", c.Index)
|
|
return nil
|
|
}
|
|
|
|
type DocsDeleteCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
Start int64 `name:"start" required:"" help:"Start index (>= 1)"`
|
|
End int64 `name:"end" required:"" help:"End index (> start)"`
|
|
}
|
|
|
|
func (c *DocsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
u := ui.FromContext(ctx)
|
|
account, err := requireAccount(flags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
docID := strings.TrimSpace(c.DocID)
|
|
if docID == "" {
|
|
return usage("empty docId")
|
|
}
|
|
|
|
if c.Start < 1 {
|
|
return usage("--start must be >= 1")
|
|
}
|
|
if c.End <= c.Start {
|
|
return usage("--end must be greater than --start")
|
|
}
|
|
|
|
svc, err := newDocsService(ctx, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
|
Requests: []*docs.Request{{
|
|
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
|
Range: &docs.Range{
|
|
StartIndex: c.Start,
|
|
EndIndex: c.End,
|
|
},
|
|
},
|
|
}},
|
|
}).Context(ctx).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("deleting content: %w", err)
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
|
"documentId": result.DocumentId,
|
|
"deleted": c.End - c.Start,
|
|
"startIndex": c.Start,
|
|
"endIndex": c.End,
|
|
})
|
|
}
|
|
|
|
u.Out().Printf("documentId\t%s", result.DocumentId)
|
|
u.Out().Printf("deleted\t%d characters", c.End-c.Start)
|
|
u.Out().Printf("range\t%d-%d", c.Start, c.End)
|
|
return nil
|
|
}
|
|
|
|
type DocsClearCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
}
|
|
|
|
func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
// Clear delegates to: gog docs sed <docId> 's/^$//'
|
|
// s/^$// with empty replacement on a non-empty doc = clear all content.
|
|
docID := strings.TrimSpace(c.DocID)
|
|
if docID == "" {
|
|
return usage("empty docId")
|
|
}
|
|
|
|
sedCmd := DocsSedCmd{
|
|
DocID: docID,
|
|
Expression: `s/^$//`,
|
|
}
|
|
return sedCmd.Run(ctx, flags)
|
|
}
|
|
|
|
// --- Structure / Numbered commands ---
|
|
|
|
// DocsStructureCmd displays document structure with numbered paragraphs.
|
|
type DocsStructureCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
Tab string `name:"tab" help:"Tab title or ID (omit for default)"`
|
|
}
|
|
|
|
func (c *DocsStructureCmd) 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
|
|
}
|
|
|
|
getCall := svc.Documents.Get(id)
|
|
if c.Tab != "" {
|
|
getCall = getCall.IncludeTabsContent(true)
|
|
}
|
|
doc, err := getCall.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")
|
|
}
|
|
|
|
pm, err := buildParagraphMap(doc, c.Tab)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, pm)
|
|
}
|
|
|
|
u.Out().Printf(" # TYPE CONTENT")
|
|
for _, p := range pm.Paragraphs {
|
|
prefix := ""
|
|
if p.IsBullet {
|
|
prefix = strings.Repeat(" ", p.NestLevel) + "* "
|
|
}
|
|
|
|
text := p.Text
|
|
if len(text) > 60 {
|
|
text = text[:57] + "..."
|
|
}
|
|
|
|
if p.ElemType == "table" {
|
|
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
|
|
}
|
|
|
|
u.Out().Printf("%2d %-18s %s%s", p.Num, p.Type, prefix, text)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// printNumbered prints document content with [N] paragraph number prefixes.
|
|
func (c *DocsCatCmd) printNumbered(ctx context.Context, doc *docs.Document, tabID string) error {
|
|
pm, err := buildParagraphMap(doc, tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, pm)
|
|
}
|
|
|
|
for _, p := range pm.Paragraphs {
|
|
text := p.Text
|
|
if p.ElemType == "table" {
|
|
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
|
|
}
|
|
if _, err := fmt.Fprintf(os.Stdout, "[%d] %s\n", p.Num, text); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type DocsFindReplaceCmd struct {
|
|
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
|
Find string `arg:"" name:"find" help:"Text to find"`
|
|
ReplaceText string `arg:"" name:"replace" help:"Replacement text"`
|
|
MatchCase bool `name:"match-case" help:"Case-sensitive matching"`
|
|
}
|
|
|
|
func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
u := ui.FromContext(ctx)
|
|
account, err := requireAccount(flags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
docID := strings.TrimSpace(c.DocID)
|
|
if docID == "" {
|
|
return usage("empty docId")
|
|
}
|
|
if c.Find == "" {
|
|
return usage("find text cannot be empty")
|
|
}
|
|
|
|
svc, err := newDocsService(ctx, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
|
Requests: []*docs.Request{{
|
|
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
|
ContainsText: &docs.SubstringMatchCriteria{
|
|
Text: c.Find,
|
|
MatchCase: c.MatchCase,
|
|
},
|
|
ReplaceText: c.ReplaceText,
|
|
},
|
|
}},
|
|
}).Context(ctx).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("find-replace: %w", err)
|
|
}
|
|
|
|
replacements := int64(0)
|
|
if len(result.Replies) > 0 && result.Replies[0].ReplaceAllText != nil {
|
|
replacements = result.Replies[0].ReplaceAllText.OccurrencesChanged
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
|
"documentId": result.DocumentId,
|
|
"find": c.Find,
|
|
"replace": c.ReplaceText,
|
|
"replacements": replacements,
|
|
})
|
|
}
|
|
|
|
u.Out().Printf("documentId\t%s", result.DocumentId)
|
|
u.Out().Printf("find\t%s", c.Find)
|
|
u.Out().Printf("replace\t%s", c.ReplaceText)
|
|
u.Out().Printf("replacements\t%d", replacements)
|
|
return nil
|
|
}
|
|
|
|
// resolveContentInput reads content from an argument, file, or stdin.
|
|
func resolveContentInput(content, filePath string) (string, error) {
|
|
if content != "" {
|
|
return content, nil
|
|
}
|
|
if filePath != "" {
|
|
if filePath == "-" {
|
|
data, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading stdin: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
data, err := os.ReadFile(filePath) //nolint:gosec // user-provided path
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading file: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
// Check if stdin has data.
|
|
stat, _ := os.Stdin.Stat()
|
|
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
|
data, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading stdin: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// flattenTabs recursively collects all tabs (including nested child tabs)
|
|
// into a flat slice in document order.
|
|
func flattenTabs(tabs []*docs.Tab) []*docs.Tab {
|
|
var result []*docs.Tab
|
|
for _, tab := range tabs {
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
result = append(result, tab)
|
|
if len(tab.ChildTabs) > 0 {
|
|
result = append(result, flattenTabs(tab.ChildTabs)...)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// findTab looks up a tab by title or ID (case-insensitive title match).
|
|
func findTab(tabs []*docs.Tab, query string) *docs.Tab {
|
|
query = strings.TrimSpace(query)
|
|
// Try exact ID match first.
|
|
for _, tab := range tabs {
|
|
if tab.TabProperties != nil && tab.TabProperties.TabId == query {
|
|
return tab
|
|
}
|
|
}
|
|
// Fall back to case-insensitive title match.
|
|
lower := strings.ToLower(query)
|
|
for _, tab := range tabs {
|
|
if tab.TabProperties != nil && strings.ToLower(tab.TabProperties.Title) == lower {
|
|
return tab
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tabTitle returns the display title for a tab.
|
|
func tabTitle(tab *docs.Tab) string {
|
|
if tab.TabProperties != nil && tab.TabProperties.Title != "" {
|
|
return tab.TabProperties.Title
|
|
}
|
|
return "(untitled)"
|
|
}
|
|
|
|
// tabPlainText extracts plain text from a tab's document content.
|
|
func tabPlainText(tab *docs.Tab, maxBytes int64) string {
|
|
if tab == nil || tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
|
|
return ""
|
|
}
|
|
var buf bytes.Buffer
|
|
for _, el := range tab.DocumentTab.Body.Content {
|
|
if !appendDocsElementText(&buf, maxBytes, el) {
|
|
break
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// tabJSON returns a JSON-friendly map for a tab with its text content.
|
|
func tabJSON(tab *docs.Tab, text string) map[string]any {
|
|
m := map[string]any{"text": text}
|
|
if tab.TabProperties != nil {
|
|
m["id"] = tab.TabProperties.TabId
|
|
m["title"] = tab.TabProperties.Title
|
|
m["index"] = tab.TabProperties.Index
|
|
}
|
|
return m
|
|
}
|
|
|
|
// tabInfoJSON returns a JSON-friendly map for a tab's metadata (no content).
|
|
func tabInfoJSON(tab *docs.Tab) map[string]any {
|
|
m := map[string]any{}
|
|
if tab.TabProperties != nil {
|
|
m["id"] = tab.TabProperties.TabId
|
|
m["title"] = tab.TabProperties.Title
|
|
m["index"] = tab.TabProperties.Index
|
|
if tab.TabProperties.NestingLevel > 0 {
|
|
m["nestingLevel"] = tab.TabProperties.NestingLevel
|
|
}
|
|
if tab.TabProperties.ParentTabId != "" {
|
|
m["parentTabId"] = tab.TabProperties.ParentTabId
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
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) {
|
|
return false
|
|
}
|
|
return apiErr.Code == http.StatusNotFound
|
|
}
|