diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 91727cd3..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)"` @@ -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 { @@ -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 @@ -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() @@ -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 } @@ -378,6 +398,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 +408,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 +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{ @@ -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() @@ -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 } @@ -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) } @@ -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 { @@ -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 } @@ -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 != "" { 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) + } +}