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
1 change: 1 addition & 0 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type DocsCmd struct {
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)"`
Format DocsFormatCmd `cmd:"" name:"format" aliases:"fmt" help:"Apply formatting (font, color, alignment) to text in a Google Doc"`
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"`
}
Expand Down
168 changes: 168 additions & 0 deletions internal/cmd/docs_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ type DocsWriteCmd struct {
Append bool `name:"append" help:"Append instead of replacing the document body"`
Pageless bool `name:"pageless" help:"Set document to pageless mode"`
TabID string `name:"tab-id" help:"Target a specific tab by ID (see docs list-tabs)"`

// Formatting flags (applied after content write)
FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia, Times New Roman)"`
FontSize float64 `name:"font-size" help:"Font size in points (e.g. 12, 14, 16)"`
TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"`
BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"`
Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"`
Underline bool `name:"underline" help:"Apply underline to written text"`
Strikethrough bool `name:"strikethrough" help:"Apply strikethrough to written text"`
LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"`
}

func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -85,6 +95,25 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
}
}

// Apply formatting if any flags set
fmtOpts := c.formattingOpts()
if fmtOpts.hasAny() {
docEnd, endErr := docsTargetEndIndex(ctx, svc, id, c.TabID)
if endErr != nil {
return fmt.Errorf("re-fetch document for formatting: %w", endErr)
}
fmtEnd := docEnd - 1
if fmtEnd > 1 {
fmtReqs := buildFormattingRequests(1, fmtEnd, fmtOpts)
if len(fmtReqs) > 0 {
_, err = svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: fmtReqs}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("apply formatting: %w", err)
}
}
}
}

if outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": resp.DocumentId,
Expand Down Expand Up @@ -114,6 +143,23 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
return nil
}

func (c *DocsWriteCmd) formattingOpts() FormattingOpts {
opts := FormattingOpts{
FontFamily: c.FontFamily, FontSize: c.FontSize,
TextColor: c.TextColor, BgColor: c.BgColor,
Alignment: c.Alignment, LineSpacing: c.LineSpacing,
}
if c.Underline {
v := true
opts.Underline = &v
}
if c.Strikethrough {
v := true
opts.Strikethrough = &v
}
return opts
}

type DocsUpdateCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to insert"`
Expand Down Expand Up @@ -543,3 +589,125 @@ func (c *DocsFindReplaceCmd) resolveReplaceText() (string, error) {
}
return string(data), nil
}

// DocsFormatCmd applies formatting to existing text in a Google Doc.
type DocsFormatCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Match string `name:"match" help:"Text to find and format (first occurrence unless --match-all)"`
All bool `name:"match-all" help:"Format all occurrences of --match text"`

FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia)"`
FontSize float64 `name:"font-size" help:"Font size in points"`
TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"`
BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"`
Bold bool `name:"bold" help:"Apply bold"`
Italic bool `name:"italic" help:"Apply italic"`
Underline bool `name:"underline" help:"Apply underline"`
Strikethrough bool `name:"strikethrough" help:"Apply strikethrough"`
Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"`
LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"`
}

func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}

opts := c.formattingOpts()
if !opts.hasAny() {
return usage("at least one formatting flag is required")
}

svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}

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
}

type fmtRange struct{ start, end int64 }
var ranges []fmtRange

if c.Match != "" {
if c.All {
matches := findTextMatches(doc, c.Match, true)
for _, m := range matches {
ranges = append(ranges, fmtRange{m.startIndex, m.endIndex})
}
} else {
start, end, total := findTextInDoc(doc, c.Match, true)
if total == 0 {
return fmt.Errorf("text %q not found in document", c.Match)
}
ranges = append(ranges, fmtRange{start, end})
}
if len(ranges) == 0 {
return fmt.Errorf("text %q not found in document", c.Match)
}
} else {
// Apply to entire document
if doc.Body != nil && len(doc.Body.Content) > 0 {
last := doc.Body.Content[len(doc.Body.Content)-1]
if last != nil && last.EndIndex > 2 {
ranges = append(ranges, fmtRange{1, last.EndIndex - 1})
}
}
if len(ranges) == 0 {
return fmt.Errorf("document is empty")
}
}

var allReqs []*docs.Request
for _, r := range ranges {
allReqs = append(allReqs, buildFormattingRequests(r.start, r.end, opts)...)
}

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

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"success": true,
"documentId": id,
"ranges": len(ranges),
})
}

u.Out().Printf("Formatted %d range(s) in document %s", len(ranges), id)
return nil
}

func (c *DocsFormatCmd) formattingOpts() FormattingOpts {
opts := FormattingOpts{
FontFamily: c.FontFamily, FontSize: c.FontSize,
TextColor: c.TextColor, BgColor: c.BgColor,
Alignment: c.Alignment, LineSpacing: c.LineSpacing,
}
if c.Bold {
v := true
opts.Bold = &v
}
if c.Italic {
v := true
opts.Italic = &v
}
if c.Underline {
v := true
opts.Underline = &v
}
if c.Strikethrough {
v := true
opts.Strikethrough = &v
}
return opts
}
151 changes: 151 additions & 0 deletions internal/cmd/docs_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,157 @@ import (
"google.golang.org/api/docs/v1"
)

// FormattingOpts holds explicit formatting options for range-based formatting.
type FormattingOpts struct {
FontFamily string
FontSize float64
TextColor string // hex #RRGGBB
BgColor string // hex #RRGGBB
Bold *bool // nil = unset
Italic *bool
Underline *bool
Strikethrough *bool
Alignment string // left|center|right|justified
LineSpacing float64 // percentage, e.g. 150 = 1.5x
SpaceAbove float64 // points
SpaceBelow float64 // points
}

// hasAny returns true if any formatting option is set.
func (o FormattingOpts) hasAny() bool {
return o.FontFamily != "" || o.FontSize != 0 ||
o.TextColor != "" || o.BgColor != "" ||
o.Bold != nil || o.Italic != nil ||
o.Underline != nil || o.Strikethrough != nil ||
o.Alignment != "" || o.LineSpacing != 0 ||
o.SpaceAbove != 0 || o.SpaceBelow != 0
}

// hexToOptionalColor converts a hex color string to a Google Docs OptionalColor.
// Reuses the existing parseHexColor from docs_sed_helpers.go.
func hexToOptionalColor(hex string) *docs.OptionalColor {
r, g, b, ok := parseHexColor(hex)
if !ok {
return nil
}
return &docs.OptionalColor{
Color: &docs.Color{
RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b},
},
}
}

// buildFormattingRequests builds UpdateTextStyle and UpdateParagraphStyle requests
// for the given range and formatting options. Only set fields are included.
func buildFormattingRequests(startIndex, endIndex int64, opts FormattingOpts) []*docs.Request {
var reqs []*docs.Request

textStyle := &docs.TextStyle{}
var textFields []string

if opts.FontFamily != "" {
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: opts.FontFamily, Weight: 400}
textFields = append(textFields, "weightedFontFamily")
}
if opts.FontSize != 0 {
textStyle.FontSize = &docs.Dimension{Magnitude: opts.FontSize, Unit: "PT"}
textFields = append(textFields, "fontSize")
}
if opts.TextColor != "" {
if c := hexToOptionalColor(opts.TextColor); c != nil {
textStyle.ForegroundColor = c
textFields = append(textFields, "foregroundColor")
}
}
if opts.BgColor != "" {
if c := hexToOptionalColor(opts.BgColor); c != nil {
textStyle.BackgroundColor = c
textFields = append(textFields, "backgroundColor")
}
}
if opts.Bold != nil {
textStyle.Bold = *opts.Bold
if !*opts.Bold {
textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Bold")
}
textFields = append(textFields, "bold")
}
if opts.Italic != nil {
textStyle.Italic = *opts.Italic
if !*opts.Italic {
textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Italic")
}
textFields = append(textFields, "italic")
}
if opts.Underline != nil {
textStyle.Underline = *opts.Underline
if !*opts.Underline {
textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Underline")
}
textFields = append(textFields, "underline")
}
if opts.Strikethrough != nil {
textStyle.Strikethrough = *opts.Strikethrough
if !*opts.Strikethrough {
textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Strikethrough")
}
textFields = append(textFields, "strikethrough")
}

if len(textFields) > 0 {
reqs = append(reqs, &docs.Request{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: startIndex, EndIndex: endIndex},
TextStyle: textStyle,
Fields: strings.Join(textFields, ","),
},
})
}

paraStyle := &docs.ParagraphStyle{}
var paraFields []string

if opts.Alignment != "" {
switch strings.ToLower(opts.Alignment) {
case "left":
paraStyle.Alignment = "START"
case "center":
paraStyle.Alignment = "CENTER"
case "right":
paraStyle.Alignment = "END"
case "justified":
paraStyle.Alignment = "JUSTIFIED"
}
if paraStyle.Alignment != "" {
paraFields = append(paraFields, "alignment")
}
}
if opts.LineSpacing != 0 {
paraStyle.LineSpacing = opts.LineSpacing
paraFields = append(paraFields, "lineSpacing")
}
if opts.SpaceAbove != 0 {
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: opts.SpaceAbove, Unit: "PT"}
paraFields = append(paraFields, "spaceAbove")
}
if opts.SpaceBelow != 0 {
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: opts.SpaceBelow, Unit: "PT"}
paraFields = append(paraFields, "spaceBelow")
}

if len(paraFields) > 0 {
reqs = append(reqs, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: startIndex, EndIndex: endIndex},
ParagraphStyle: paraStyle,
Fields: strings.Join(paraFields, ","),
},
})
}

return reqs
}

// Debug flag for markdown formatter
var debugMarkdown = false

Expand Down