Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
253 changes: 231 additions & 22 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type DocsCmd struct {
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"`
Color DocsColorCmd `cmd:"" name:"color" help:"Apply text color to existing text in a 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)"`
Expand Down Expand Up @@ -275,10 +276,11 @@ func (c *DocsCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
}

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"`
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"`
TextColor string `name:"text-color" help:"Set text color (hex #RRGGBB or name: red, blue, green, ...)"`
}

func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -329,6 +331,15 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
insertIndex = docsAppendIndex(endIndex)
}

// Parse text color if specified
var textColor *docs.OptionalColor
if c.TextColor != "" {
textColor, err = ParseTextColor(c.TextColor)
if err != nil {
return err
}
}

reqs := []*docs.Request{}
if !c.Append {
deleteEnd := endIndex - 1
Expand All @@ -351,6 +362,12 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
},
})

// Apply text color to inserted text
if textColor != nil {
textEnd := insertIndex + utf16Len(text)
reqs = append(reqs, BuildColorRequest(insertIndex, textEnd, textColor))
}

resp, err := svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: reqs}).
Context(ctx).
Do()
Expand All @@ -368,6 +385,9 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
"append": c.Append,
"index": insertIndex,
}
if textColor != nil {
payload["textColor"] = c.TextColor
}
if resp.WriteControl != nil {
payload["writeControl"] = resp.WriteControl
}
Expand All @@ -378,17 +398,21 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
u.Out().Printf("requests\t%d", len(reqs))
u.Out().Printf("append\t%t", c.Append)
u.Out().Printf("index\t%d", insertIndex)
if textColor != nil {
u.Out().Printf("textColor\t%s", c.TextColor)
}
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)"`
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)"`
TextColor string `name:"text-color" help:"Set text color (hex #RRGGBB or name: red, blue, green, ...)"`
}

func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -442,6 +466,15 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
insertIndex = docsAppendIndex(docsDocumentEndIndex(doc))
}

// Parse text color if specified
var textColor *docs.OptionalColor
if c.TextColor != "" {
textColor, err = ParseTextColor(c.TextColor)
if err != nil {
return err
}
}

reqs := []*docs.Request{
{
InsertText: &docs.InsertTextRequest{
Expand All @@ -451,6 +484,12 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
},
}

// Apply text color to inserted text
if textColor != nil {
textEnd := insertIndex + utf16Len(text)
reqs = append(reqs, BuildColorRequest(insertIndex, textEnd, textColor))
}

resp, err := svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: reqs}).
Context(ctx).
Do()
Expand All @@ -467,6 +506,9 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
"requests": len(reqs),
"index": insertIndex,
}
if textColor != nil {
payload["textColor"] = c.TextColor
}
if resp.WriteControl != nil {
payload["writeControl"] = resp.WriteControl
}
Expand All @@ -476,6 +518,9 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
u.Out().Printf("id\t%s", resp.DocumentId)
u.Out().Printf("requests\t%d", len(reqs))
u.Out().Printf("index\t%d", insertIndex)
if textColor != nil {
u.Out().Printf("textColor\t%s", c.TextColor)
}
if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" {
u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId)
}
Expand Down Expand Up @@ -695,10 +740,11 @@ func (c *DocsListTabsCmd) Run(ctx context.Context, flags *RootFlags) error {
// --- 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)"`
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)"`
TextColor string `name:"text-color" help:"Set text color (hex #RRGGBB or name: red, blue, green, ...)"`
}

func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -730,31 +776,56 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: []*docs.Request{{
InsertText: &docs.InsertTextRequest{
Text: content,
Location: &docs.Location{
Index: c.Index,
},
// Parse text color if specified
var textColor *docs.OptionalColor
if c.TextColor != "" {
var colorErr error
textColor, colorErr = ParseTextColor(c.TextColor)
if colorErr != nil {
return colorErr
}
}

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

// Apply text color to inserted text
if textColor != nil {
textEnd := c.Index + utf16Len(content)
reqs = append(reqs, BuildColorRequest(c.Index, textEnd, textColor))
}

result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: reqs,
}).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{
payload := map[string]any{
"documentId": result.DocumentId,
"inserted": len(content),
"atIndex": c.Index,
})
}
if textColor != nil {
payload["textColor"] = c.TextColor
}
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}

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)
if textColor != nil {
u.Out().Printf("textColor\t%s", c.TextColor)
}
return nil
}

Expand Down Expand Up @@ -992,6 +1063,144 @@ func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}

type DocsColorCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Find string `arg:"" name:"find" help:"Text to find and color"`
TextColor string `arg:"" name:"color" help:"Text color (hex #RRGGBB or name: red, blue, green, ...)"`
MatchCase bool `name:"match-case" help:"Case-sensitive matching" default:"true"`
All bool `name:"all" help:"Color all occurrences (default: first only)"`
Paragraph bool `name:"paragraph" short:"P" help:"Color the entire paragraph(s) containing the match"`
}

func (c *DocsColorCmd) 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")
}

color, err := ParseTextColor(c.TextColor)
if err != nil {
return fmt.Errorf("invalid color: %w", err)
}

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

// Get document structure to find text indices
doc, err := svc.Documents.Get(docID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get document: %w", err)
}

// Walk structural elements to find the text
type match struct {
start int64
end int64
}
var matches []match

findText := c.Find
for _, elem := range doc.Body.Content {
if elem.Paragraph == nil {
continue
}
// Build full paragraph text and track element boundaries
var paraText string
var paraStart, paraEnd int64
if len(elem.Paragraph.Elements) > 0 {
paraStart = elem.Paragraph.Elements[0].StartIndex
paraEnd = elem.Paragraph.Elements[len(elem.Paragraph.Elements)-1].EndIndex
}
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun != nil {
paraText += pe.TextRun.Content
}
}

// Search within the paragraph text
searchIn := paraText
searchFor := findText
if !c.MatchCase {
searchIn = strings.ToLower(searchIn)
searchFor = strings.ToLower(searchFor)
}

offset := 0
for {
idx := strings.Index(searchIn[offset:], searchFor)
if idx < 0 {
break
}
if c.Paragraph {
// Color the entire paragraph, excluding trailing newline
end := paraEnd
if len(paraText) > 0 && paraText[len(paraText)-1] == '\n' {
end--
}
matches = append(matches, match{start: paraStart, end: end})
} else {
absIdx := offset + idx
start := paraStart + int64(absIdx)
end := start + int64(len(findText))
matches = append(matches, match{start: start, end: end})
}
if !c.All {
break
}
offset = offset + idx + len(searchFor)
if c.Paragraph {
break // one match per paragraph is enough
}
}
if len(matches) > 0 && !c.All {
break
}
}

if len(matches) == 0 {
return fmt.Errorf("text not found in document: %q", c.Find)
}

// Build color requests
var reqs []*docs.Request
for _, m := range matches {
reqs = append(reqs, BuildColorRequest(m.start, m.end, color))
}

_, err = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: reqs,
}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("apply color: %w", err)
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"documentId": docID,
"find": c.Find,
"color": c.TextColor,
"matches": len(matches),
})
}

u.Out().Printf("documentId\t%s", docID)
u.Out().Printf("find\t%s", c.Find)
u.Out().Printf("color\t%s", c.TextColor)
u.Out().Printf("matches\t%d", len(matches))
return nil
}

// resolveContentInput reads content from an argument, file, or stdin.
func resolveContentInput(content, filePath string) (string, error) {
if content != "" {
Expand Down
Loading