gogcli/internal/cmd/docs_format.go
2026-05-04 08:28:38 +01:00

326 lines
9.9 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type DocsFormatCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Match string `name:"match" help:"Only format the first text match"`
MatchAll bool `name:"match-all" help:"Format all matches instead of only the first"`
MatchCase bool `name:"match-case" help:"Use case-sensitive matching with --match"`
Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"`
TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"`
Format DocsFormatFlags `embed:""`
}
type DocsFormatFlags struct {
FontFamily string `name:"font-family" help:"Font family, for example Arial or Georgia"`
FontSize float64 `name:"font-size" help:"Font size in points"`
TextColor string `name:"text-color" help:"Text color as #RRGGBB or #RGB"`
BgColor string `name:"bg-color" help:"Text background color as #RRGGBB or #RGB"`
Bold bool `name:"bold" help:"Set bold"`
NoBold bool `name:"no-bold" help:"Clear bold"`
Italic bool `name:"italic" help:"Set italic"`
NoItalic bool `name:"no-italic" help:"Clear italic"`
Underline bool `name:"underline" help:"Set underline"`
NoUnderline bool `name:"no-underline" help:"Clear underline"`
Strikethrough bool `name:"strikethrough" aliases:"strike" help:"Set strikethrough"`
NoStrike bool `name:"no-strikethrough" aliases:"no-strike" help:"Clear strikethrough"`
Alignment string `name:"alignment" help:"Paragraph alignment: left, center, right, justify, start, end, justified"`
LineSpacing float64 `name:"line-spacing" help:"Paragraph line spacing percentage, for example 100 or 150"`
}
func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}
if !c.Format.any() {
return usage("no formatting flags provided")
}
if c.MatchAll && strings.TrimSpace(c.Match) == "" {
return usage("--match-all requires --match")
}
tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID)
if tabErr != nil {
return tabErr
}
c.Tab = tab
svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}
ranges, tabID, err := c.targetRanges(ctx, svc, id)
if err != nil {
return err
}
if len(ranges) == 0 {
return usage("no matching text found")
}
reqs := make([]*docs.Request, 0, len(ranges)*2)
for _, r := range ranges {
formatReqs, buildErr := c.Format.buildRequests(r.startIndex, r.endIndex, tabID)
if buildErr != nil {
return buildErr
}
reqs = append(reqs, formatReqs...)
}
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
}
return c.writeResult(ctx, resp, len(reqs), len(ranges), tabID)
}
func (c *DocsFormatCmd) targetRanges(ctx context.Context, svc *docs.Service, docID string) ([]docRange, string, error) {
if strings.TrimSpace(c.Match) == "" {
endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab)
if err != nil {
return nil, "", err
}
end := endIndex - 1
if end <= 1 {
return nil, tabID, nil
}
return []docRange{{startIndex: 1, endIndex: end}}, tabID, nil
}
getCall := svc.Documents.Get(docID).Context(ctx)
if c.Tab != "" {
getCall = getCall.IncludeTabsContent(true)
}
doc, err := getCall.Do()
if err != nil {
if isDocsNotFound(err) {
return nil, "", fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
}
return nil, "", err
}
tabID := ""
targetDoc := doc
if c.Tab != "" {
tab, tabErr := findTab(flattenTabs(doc.Tabs), c.Tab)
if tabErr != nil {
return nil, "", tabErr
}
if tab.TabProperties != nil {
tabID = tab.TabProperties.TabId
}
targetDoc = &docs.Document{}
if tab.DocumentTab != nil {
targetDoc.Body = tab.DocumentTab.Body
}
}
matches := findTextMatches(targetDoc, c.Match, c.MatchCase)
if !c.MatchAll && len(matches) > 1 {
matches = matches[:1]
}
return matches, tabID, nil
}
func (c *DocsFormatCmd) writeResult(ctx context.Context, resp *docs.BatchUpdateDocumentResponse, requestCount, rangeCount int, tabID string) error {
u := ui.FromContext(ctx)
if outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": resp.DocumentId,
"requests": requestCount,
"ranges": rangeCount,
}
if tabID != "" {
payload["tabId"] = tabID
}
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", requestCount)
u.Out().Printf("ranges\t%d", rangeCount)
if tabID != "" {
u.Out().Printf("tabId\t%s", tabID)
}
if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" {
u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId)
}
return nil
}
func (f DocsFormatFlags) any() bool {
return strings.TrimSpace(f.FontFamily) != "" ||
f.FontSize != 0 ||
strings.TrimSpace(f.TextColor) != "" ||
strings.TrimSpace(f.BgColor) != "" ||
f.Bold || f.NoBold ||
f.Italic || f.NoItalic ||
f.Underline || f.NoUnderline ||
f.Strikethrough || f.NoStrike ||
strings.TrimSpace(f.Alignment) != "" ||
f.LineSpacing != 0
}
func (f DocsFormatFlags) buildRequests(start, end int64, tabID string) ([]*docs.Request, error) {
if start <= 0 || end <= start {
return nil, fmt.Errorf("invalid format range: %d..%d", start, end)
}
textReq, ok, err := f.buildTextStyleRequest(start, end, tabID)
if err != nil {
return nil, err
}
paraReq, paraOK, err := f.buildParagraphStyleRequest(start, end, tabID)
if err != nil {
return nil, err
}
reqs := make([]*docs.Request, 0, 2)
if ok {
reqs = append(reqs, textReq)
}
if paraOK {
reqs = append(reqs, paraReq)
}
if len(reqs) == 0 {
return nil, usage("no formatting flags provided")
}
return reqs, nil
}
func (f DocsFormatFlags) buildTextStyleRequest(start, end int64, tabID string) (*docs.Request, bool, error) {
style := &docs.TextStyle{}
var fields []string
if font := strings.TrimSpace(f.FontFamily); font != "" {
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: font}
fields = append(fields, "weightedFontFamily")
}
if f.FontSize < 0 {
return nil, false, usage("--font-size must be positive")
}
if f.FontSize > 0 {
style.FontSize = &docs.Dimension{Magnitude: f.FontSize, Unit: "PT"}
fields = append(fields, "fontSize")
}
if color := strings.TrimSpace(f.TextColor); color != "" {
optionalColor, err := docsFormatColor(color, "--text-color")
if err != nil {
return nil, false, err
}
style.ForegroundColor = optionalColor
fields = append(fields, "foregroundColor")
}
if color := strings.TrimSpace(f.BgColor); color != "" {
optionalColor, err := docsFormatColor(color, "--bg-color")
if err != nil {
return nil, false, err
}
style.BackgroundColor = optionalColor
fields = append(fields, "backgroundColor")
}
addBoolStyle := func(set, unset bool, field, forceField string, apply func(bool)) error {
if set && unset {
return usage(fmt.Sprintf("--%s and --no-%s cannot be combined", field, field))
}
if set || unset {
apply(set)
fields = append(fields, field)
if unset {
style.ForceSendFields = append(style.ForceSendFields, forceField)
}
}
return nil
}
if err := addBoolStyle(f.Bold, f.NoBold, "bold", "Bold", func(v bool) { style.Bold = v }); err != nil {
return nil, false, err
}
if err := addBoolStyle(f.Italic, f.NoItalic, "italic", "Italic", func(v bool) { style.Italic = v }); err != nil {
return nil, false, err
}
if err := addBoolStyle(f.Underline, f.NoUnderline, "underline", "Underline", func(v bool) { style.Underline = v }); err != nil {
return nil, false, err
}
if err := addBoolStyle(f.Strikethrough, f.NoStrike, "strikethrough", "Strikethrough", func(v bool) { style.Strikethrough = v }); err != nil {
return nil, false, err
}
if len(fields) == 0 {
return nil, false, nil
}
return &docs.Request{UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end, TabId: tabID},
TextStyle: style,
Fields: strings.Join(fields, ","),
}}, true, nil
}
func (f DocsFormatFlags) buildParagraphStyleRequest(start, end int64, tabID string) (*docs.Request, bool, error) {
style := &docs.ParagraphStyle{}
var fields []string
if align := strings.TrimSpace(f.Alignment); align != "" {
resolved, err := docsFormatAlignment(align)
if err != nil {
return nil, false, err
}
style.Alignment = resolved
fields = append(fields, "alignment")
}
if f.LineSpacing < 0 {
return nil, false, usage("--line-spacing must be positive")
}
if f.LineSpacing > 0 {
style.LineSpacing = f.LineSpacing
fields = append(fields, "lineSpacing")
}
if len(fields) == 0 {
return nil, false, nil
}
return &docs.Request{UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end, TabId: tabID},
ParagraphStyle: style,
Fields: strings.Join(fields, ","),
}}, true, nil
}
func docsFormatColor(hex, flag string) (*docs.OptionalColor, error) {
r, g, b, ok := parseHexColor(hex)
if !ok {
return nil, usage(fmt.Sprintf("%s must be #RRGGBB or #RGB", flag))
}
return &docs.OptionalColor{Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}}}, nil
}
func docsFormatAlignment(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "left", "start":
return "START", nil
case "center", "centre":
return "CENTER", nil
case "right", "end":
return "END", nil
case "justify", "justified":
return "JUSTIFIED", nil
default:
return "", usage("--alignment must be left, center, right, justify, start, end, or justified")
}
}