From 04792861e05a3a58ac007819868425f510c0f40a Mon Sep 17 00:00:00 2001 From: Sixel Date: Wed, 4 Mar 2026 07:07:17 +0000 Subject: [PATCH 1/2] feat(docs): add --text-color flag to write, insert, and update commands Adds the ability to set foreground text color when writing or inserting text into Google Docs. Accepts named colors (red, blue, green, etc.) and hex colors (#RRGGBB, #RGB). The color is applied as an UpdateTextStyleRequest in the same batch as the text insertion. Usage: gog docs write --text "hello" --text-color blue gog docs insert "note" --text-color "#FF6600" gog docs update --text "text" --text-color red Includes ParseTextColor() and BuildColorRequest() helpers, plus unit tests for named colors, hex parsing, and request construction. Co-Authored-By: Claude Opus 4.6 --- internal/cmd/docs.go | 114 ++++++++++++++++++++++------ internal/cmd/docs_formatter.go | 88 +++++++++++++++++++++ internal/cmd/docs_formatter_test.go | 112 ++++++++++++++++++++++++++- 3 files changed, 291 insertions(+), 23 deletions(-) diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 91727cd3..5ab03917 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -275,10 +275,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 { @@ -329,6 +330,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 @@ -351,6 +361,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() @@ -368,6 +384,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 } @@ -378,6 +397,9 @@ 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) } @@ -385,10 +407,11 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF } 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 { @@ -442,6 +465,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{ @@ -451,6 +483,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() @@ -467,6 +505,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 } @@ -476,6 +517,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) } @@ -695,10 +739,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 { @@ -730,31 +775,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 } diff --git a/internal/cmd/docs_formatter.go b/internal/cmd/docs_formatter.go index 079cc4b6..816391dc 100644 --- a/internal/cmd/docs_formatter.go +++ b/internal/cmd/docs_formatter.go @@ -315,6 +315,94 @@ func buildTextStyleRequest(style TextStyle, baseOffset int64) *docs.Request { } } +// ParseTextColor parses a color string and returns an OptionalColor. +// Accepts hex colors (#RRGGBB, #RGB) and named colors (red, blue, green, etc.). +func ParseTextColor(color string) (*docs.OptionalColor, error) { + color = strings.TrimSpace(strings.ToLower(color)) + if color == "" { + return nil, fmt.Errorf("empty color") + } + + // Named colors + namedColors := map[string][3]float64{ + "red": {1.0, 0.0, 0.0}, + "green": {0.0, 0.5, 0.0}, + "blue": {0.0, 0.0, 1.0}, + "black": {0.0, 0.0, 0.0}, + "white": {1.0, 1.0, 1.0}, + "gray": {0.5, 0.5, 0.5}, + "grey": {0.5, 0.5, 0.5}, + "orange": {1.0, 0.647, 0.0}, + "purple": {0.5, 0.0, 0.5}, + "cyan": {0.0, 1.0, 1.0}, + "magenta": {1.0, 0.0, 1.0}, + "yellow": {1.0, 1.0, 0.0}, + "brown": {0.6, 0.3, 0.0}, + "teal": {0.0, 0.5, 0.5}, + "navy": {0.0, 0.0, 0.5}, + } + + if rgb, ok := namedColors[color]; ok { + return &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Red: rgb[0], + Green: rgb[1], + Blue: rgb[2], + }, + }, + }, nil + } + + // Hex colors + hex := strings.TrimPrefix(color, "#") + var r, g, b uint8 + switch len(hex) { + case 3: + _, err := fmt.Sscanf(hex, "%1x%1x%1x", &r, &g, &b) + if err != nil { + return nil, fmt.Errorf("invalid hex color %q: %w", color, err) + } + r = r*16 + r + g = g*16 + g + b = b*16 + b + case 6: + _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + if err != nil { + return nil, fmt.Errorf("invalid hex color %q: %w", color, err) + } + default: + return nil, fmt.Errorf("unknown color %q (use hex #RRGGBB or name: red, blue, green, ...)", color) + } + + return &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Red: float64(r) / 255.0, + Green: float64(g) / 255.0, + Blue: float64(b) / 255.0, + }, + }, + }, nil +} + +// BuildColorRequest creates an UpdateTextStyleRequest that sets the foreground +// color of text in the given range. +func BuildColorRequest(startIndex, endIndex int64, color *docs.OptionalColor) *docs.Request { + return &docs.Request{ + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Range: &docs.Range{ + StartIndex: startIndex, + EndIndex: endIndex, + }, + TextStyle: &docs.TextStyle{ + ForegroundColor: color, + }, + Fields: "foregroundColor", + }, + } +} + func getHeadingStyle(elType MarkdownElementType) string { switch elType { case MDHeading1: diff --git a/internal/cmd/docs_formatter_test.go b/internal/cmd/docs_formatter_test.go index a431022c..24b99e26 100644 --- a/internal/cmd/docs_formatter_test.go +++ b/internal/cmd/docs_formatter_test.go @@ -1,6 +1,9 @@ package cmd -import "testing" +import ( + "math" + "testing" +) func TestMarkdownToDocsRequests_BaseIndex(t *testing.T) { elements := []MarkdownElement{{Type: MDParagraph, Content: "**bold**"}} @@ -39,3 +42,110 @@ func TestMarkdownToDocsRequests_TableStartIndexUsesBase(t *testing.T) { t.Fatalf("unexpected table start index: %d", tables[0].StartIndex) } } + +func TestParseTextColor_NamedColors(t *testing.T) { + tests := []struct { + name string + input string + wantR float64 + wantG float64 + wantB float64 + wantErr bool + }{ + {"red", "red", 1.0, 0.0, 0.0, false}, + {"blue", "blue", 0.0, 0.0, 1.0, false}, + {"green", "green", 0.0, 0.5, 0.0, false}, + {"uppercase", "RED", 1.0, 0.0, 0.0, false}, + {"mixed case", "Blue", 0.0, 0.0, 1.0, false}, + {"unknown", "chartreuse", 0, 0, 0, true}, + {"empty", "", 0, 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + color, err := ParseTextColor(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rgb := color.Color.RgbColor + if rgb.Red != tt.wantR || rgb.Green != tt.wantG || rgb.Blue != tt.wantB { + t.Fatalf("color mismatch: got R=%.2f G=%.2f B=%.2f, want R=%.2f G=%.2f B=%.2f", + rgb.Red, rgb.Green, rgb.Blue, tt.wantR, tt.wantG, tt.wantB) + } + }) + } +} + +func TestParseTextColor_HexColors(t *testing.T) { + tests := []struct { + name string + input string + wantR float64 + wantG float64 + wantB float64 + wantErr bool + }{ + {"6-digit", "#FF0000", 1.0, 0.0, 0.0, false}, + {"6-digit blue", "#0000FF", 0.0, 0.0, 1.0, false}, + {"6-digit mixed", "#006400", 0.0, 100.0 / 255.0, 0.0, false}, + {"3-digit", "#F00", 1.0, 0.0, 0.0, false}, + {"3-digit white", "#FFF", 1.0, 1.0, 1.0, false}, + {"no hash", "FF0000", 1.0, 0.0, 0.0, false}, + {"invalid hex", "#GGGGGG", 0, 0, 0, true}, + {"wrong length", "#FFFF", 0, 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + color, err := ParseTextColor(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rgb := color.Color.RgbColor + if math.Abs(rgb.Red-tt.wantR) > 0.01 || + math.Abs(rgb.Green-tt.wantG) > 0.01 || + math.Abs(rgb.Blue-tt.wantB) > 0.01 { + t.Fatalf("color mismatch: got R=%.4f G=%.4f B=%.4f, want R=%.4f G=%.4f B=%.4f", + rgb.Red, rgb.Green, rgb.Blue, tt.wantR, tt.wantG, tt.wantB) + } + }) + } +} + +func TestBuildColorRequest(t *testing.T) { + color, err := ParseTextColor("blue") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := BuildColorRequest(10, 50, color) + if req == nil { + t.Fatal("expected non-nil request") + } + if req.UpdateTextStyle == nil { + t.Fatal("expected UpdateTextStyle request") + } + if req.UpdateTextStyle.Range.StartIndex != 10 || req.UpdateTextStyle.Range.EndIndex != 50 { + t.Fatalf("unexpected range: [%d, %d]", req.UpdateTextStyle.Range.StartIndex, req.UpdateTextStyle.Range.EndIndex) + } + if req.UpdateTextStyle.Fields != "foregroundColor" { + t.Fatalf("unexpected fields: %q", req.UpdateTextStyle.Fields) + } + fg := req.UpdateTextStyle.TextStyle.ForegroundColor + if fg == nil || fg.Color == nil || fg.Color.RgbColor == nil { + t.Fatal("expected foreground color to be set") + } + if fg.Color.RgbColor.Blue != 1.0 { + t.Fatalf("expected blue=1.0, got %f", fg.Color.RgbColor.Blue) + } +} From c7907601dca03be1fb5c0c7b38f51b28d682b8da Mon Sep 17 00:00:00 2001 From: Sixel Date: Wed, 4 Mar 2026 16:58:03 +0000 Subject: [PATCH 2/2] feat: add `docs color` subcommand for coloring existing text Applies foreground color to text found in a Google Doc using the Docs API structural indices (not plain text offsets). Supports --paragraph flag to color the entire containing paragraph. Usage: gog docs color "text to find" gog docs color "text" "#a64d79" --paragraph gog docs color "text" blue --all This solves the problem where find-replace loses color: you can find-replace first, then color the result. Co-Authored-By: Claude Opus 4.6 --- internal/cmd/docs.go | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 5ab03917..48fd2e06 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -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)"` @@ -1062,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 != "" {