diff --git a/CHANGELOG.md b/CHANGELOG.md index a2102b3..9e49928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Install: publish a GHCR Docker image for release tags, with a non-root runtime image and file-keyring docs for container automation. (#539, #444) — thanks @HuckOps and @rdehuyss. - Gmail: add `--sanitize-content` (`--safe`) to `gmail get` and `gmail thread get` for agent-oriented sanitized content output without raw Gmail payloads in JSON. (#238, #220) — thanks @urasmutlu. - Raw API dumps: add `docs raw`, `sheets raw`, `slides raw`, `drive raw`, `gmail raw`, `calendar raw`, `people raw`, `contacts raw`, `tasks raw`, and `forms raw` subcommands for lossless Google API JSON output, with `--pretty`, Drive raw redaction defaults, Sheets grid-data warnings, and a raw-output security audit. (#495, #496) — thanks @karbassi. +- Docs: add `docs format` and plain-text `docs write` formatting flags for fonts, colors, bold/italic/underline/strikethrough, alignment, and line spacing. (#479) — thanks @mmaghsoodnia. - Drive: add `--fields` to `drive ls` and `drive get` so callers can pass Drive API field masks for fields beyond the default JSON set. (#495) — thanks @karbassi. - Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield. - Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal. diff --git a/README.md b/README.md index 04086ff..c347d21 100644 --- a/README.md +++ b/README.md @@ -1191,9 +1191,13 @@ gog docs update --text "Only in this tab" --tab "Notes" gog docs update --file ./insert.txt --index 25 --pageless gog docs write --text "Fresh content" gog docs write --text "Rewrite one tab" --tab "Notes" +gog docs write --text "Important" --bold --text-color "#3366cc" gog docs write --file ./body.txt --append --pageless gog docs write --file ./body.md --replace --markdown gog docs write --file ./body.md --append --markdown +gog docs format --match "Important" --bold --bg-color "#fff2cc" +gog docs format --match "todo" --match-all --no-bold --underline +gog docs format --alignment center --line-spacing 150 gog docs find-replace "old" "new" gog docs find-replace "old" "new" --tab "Notes" gog docs raw # Lossless JSON dump of Documents.Get (LLM/scripting) diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 00a4b90..1a57f6f 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -222,6 +222,7 @@ Generated from `gog schema --json`. - [`gog docs (doc) edit [flags]`](commands/gog-docs-edit.md) - Find and replace text in a Google Doc - [`gog docs (doc) export (download,dl) [flags]`](commands/gog-docs-export.md) - Export a Google Doc (pdf|docx|txt|md|html) - [`gog docs (doc) find-replace [] [flags]`](commands/gog-docs-find-replace.md) - Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence. + - [`gog docs (doc) format [flags]`](commands/gog-docs-format.md) - Apply text or paragraph formatting to a Google Doc - [`gog docs (doc) info (get,show) `](commands/gog-docs-info.md) - Get Google Doc metadata - [`gog docs (doc) insert [] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position - [`gog docs (doc) list-tabs `](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc diff --git a/docs/commands/README.md b/docs/commands/README.md index c3909d4..db2dee6 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 450. +Generated pages: 451. ## Top-level Commands @@ -265,6 +265,7 @@ Generated pages: 450. - [gog docs edit](gog-docs-edit.md) - Find and replace text in a Google Doc - [gog docs export](gog-docs-export.md) - Export a Google Doc (pdf|docx|txt|md|html) - [gog docs find-replace](gog-docs-find-replace.md) - Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence. + - [gog docs format](gog-docs-format.md) - Apply text or paragraph formatting to a Google Doc - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc diff --git a/docs/commands/gog-docs-format.md b/docs/commands/gog-docs-format.md new file mode 100644 index 0000000..8109791 --- /dev/null +++ b/docs/commands/gog-docs-format.md @@ -0,0 +1,60 @@ +# `gog docs format` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Apply text or paragraph formatting to a Google Doc + +## Usage + +```bash +gog docs (doc) format [flags] +``` + +## Parent + +- [gog docs](gog-docs.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--alignment` | `string` | | Paragraph alignment: left, center, right, justify, start, end, justified | +| `--bg-color` | `string` | | Text background color as #RRGGBB or #RGB | +| `--bold` | `bool` | | Set bold | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `--font-family` | `string` | | Font family, for example Arial or Georgia | +| `--font-size` | `float64` | | Font size in points | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--italic` | `bool` | | Set italic | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--line-spacing` | `float64` | | Paragraph line spacing percentage, for example 100 or 150 | +| `--match` | `string` | | Only format the first text match | +| `--match-all` | `bool` | | Format all matches instead of only the first | +| `--match-case` | `bool` | | Use case-sensitive matching with --match | +| `--no-bold` | `bool` | | Clear bold | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--no-italic` | `bool` | | Clear italic | +| `--no-strikethrough`
`--no-strike` | `bool` | | Clear strikethrough | +| `--no-underline` | `bool` | | Clear underline | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--strikethrough`
`--strike` | `bool` | | Set strikethrough | +| `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) | +| `--text-color` | `string` | | Text color as #RRGGBB or #RGB | +| `--underline` | `bool` | | Set underline | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog docs](gog-docs.md) +- [Command index](README.md) diff --git a/docs/commands/gog-docs-write.md b/docs/commands/gog-docs-write.md index cac774a..8499065 100644 --- a/docs/commands/gog-docs-write.md +++ b/docs/commands/gog-docs-write.md @@ -20,26 +20,40 @@ gog docs (doc) write [flags] | --- | --- | --- | --- | | `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | | `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--alignment` | `string` | | Paragraph alignment: left, center, right, justify, start, end, justified | | `--append` | `bool` | | Append instead of replacing the document body | +| `--bg-color` | `string` | | Text background color as #RRGGBB or #RGB | +| `--bold` | `bool` | | Set bold | | `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | | `--color` | `string` | auto | Color output: auto\|always\|never | | `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | | `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | | `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | | `--file` | `string` | | Text file path ('-' for stdin) | +| `--font-family` | `string` | | Font family, for example Arial or Georgia | +| `--font-size` | `float64` | | Font size in points | | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--italic` | `bool` | | Set italic | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--line-spacing` | `float64` | | Paragraph line spacing percentage, for example 100 or 150 | | `--markdown` | `bool` | | Convert markdown to Google Docs formatting (requires --replace or --append) | +| `--no-bold` | `bool` | | Clear bold | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--no-italic` | `bool` | | Clear italic | +| `--no-strikethrough`
`--no-strike` | `bool` | | Clear strikethrough | +| `--no-underline` | `bool` | | Clear underline | | `--pageless` | `bool` | | Set document to pageless mode | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--replace` | `bool` | | Replace all content explicitly (required with --markdown unless --append is set) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--strikethrough`
`--strike` | `bool` | | Set strikethrough | | `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) | | `--text` | `string` | | Text to write | +| `--text-color` | `string` | | Text color as #RRGGBB or #RGB | +| `--underline` | `bool` | | Set underline | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | diff --git a/docs/commands/gog-docs.md b/docs/commands/gog-docs.md index ee52694..ae1cb38 100644 --- a/docs/commands/gog-docs.md +++ b/docs/commands/gog-docs.md @@ -27,6 +27,7 @@ gog docs (doc) [flags] - [gog docs edit](gog-docs-edit.md) - Find and replace text in a Google Doc - [gog docs export](gog-docs-export.md) - Export a Google Doc (pdf|docx|txt|md|html) - [gog docs find-replace](gog-docs-find-replace.md) - Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence. +- [gog docs format](gog-docs-format.md) - Apply text or paragraph formatting to a Google Doc - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 954d3ea..1309e04 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -34,6 +34,7 @@ type DocsCmd struct { FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence."` 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"` + Format DocsFormatCmd `cmd:"" name:"format" help:"Apply text or paragraph formatting to a Google Doc"` Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` 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"` diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index e02d6e1..ab20ba8 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -33,15 +33,16 @@ func resolveTabArg(ctx context.Context, tab, tabID string) (string, 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)"` - Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown unless --append is set)"` - Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"` - Append bool `name:"append" help:"Append instead of replacing the document body"` - Pageless bool `name:"pageless" help:"Set document to pageless mode"` - 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"` + 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)"` + Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown unless --append is set)"` + Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"` + Append bool `name:"append" help:"Append instead of replacing the document body"` + Pageless bool `name:"pageless" help:"Set document to pageless mode"` + 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:""` } func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { @@ -65,6 +66,9 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF c.Tab = tab if c.Markdown { + if c.Format.any() { + return usage("formatting flags are only supported for plain-text docs write; use markdown syntax or run docs format after writing") + } return c.writeMarkdown(ctx, flags, id, text) } @@ -101,7 +105,10 @@ func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, doc insertIndex = docsAppendIndex(endIndex) } - reqs := c.buildPlainWriteRequests(endIndex, insertIndex, text) + reqs, err := c.buildPlainWriteRequests(endIndex, insertIndex, text) + if err != nil { + return err + } resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{Requests: reqs}).Context(ctx).Do() if err != nil { if isDocsNotFound(err) { @@ -116,7 +123,7 @@ func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, doc return c.writePlainTextResult(ctx, resp, len(reqs), insertIndex) } -func (c *DocsWriteCmd) buildPlainWriteRequests(endIndex, insertIndex int64, text string) []*docs.Request { +func (c *DocsWriteCmd) buildPlainWriteRequests(endIndex, insertIndex int64, text string) ([]*docs.Request, error) { reqs := make([]*docs.Request, 0, 2) if !c.Append { deleteEnd := endIndex - 1 @@ -134,7 +141,14 @@ func (c *DocsWriteCmd) buildPlainWriteRequests(endIndex, insertIndex int64, text Text: text, }, }) - return reqs + if c.Format.any() { + formatReqs, err := c.Format.buildRequests(insertIndex, insertIndex+utf16Len(text), c.Tab) + if err != nil { + return nil, err + } + reqs = append(reqs, formatReqs...) + } + return reqs, nil } func (c *DocsWriteCmd) applyPageless(ctx context.Context, svc *docs.Service, docID string) error { diff --git a/internal/cmd/docs_format.go b/internal/cmd/docs_format.go new file mode 100644 index 0000000..e9fbc58 --- /dev/null +++ b/internal/cmd/docs_format.go @@ -0,0 +1,325 @@ +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") + } +} diff --git a/internal/cmd/docs_format_test.go b/internal/cmd/docs_format_test.go new file mode 100644 index 0000000..14e33ed --- /dev/null +++ b/internal/cmd/docs_format_test.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" +) + +func TestDocsFormatFlagsBuildRequests(t *testing.T) { + reqs, err := (DocsFormatFlags{ + FontFamily: "Georgia", + FontSize: 14, + TextColor: "#3366cc", + BgColor: "#fff", + NoBold: true, + Italic: true, + Alignment: "center", + LineSpacing: 150, + }).buildRequests(3, 9, "t.second") + if err != nil { + t.Fatalf("buildRequests: %v", err) + } + if len(reqs) != 2 { + t.Fatalf("expected text and paragraph requests, got %d", len(reqs)) + } + + textReq := reqs[0].UpdateTextStyle + if textReq == nil { + t.Fatalf("missing text request: %#v", reqs[0]) + } + if got := textReq.Range; got.StartIndex != 3 || got.EndIndex != 9 || got.TabId != "t.second" { + t.Fatalf("unexpected text range: %#v", got) + } + if textReq.Fields != "weightedFontFamily,fontSize,foregroundColor,backgroundColor,bold,italic" { + t.Fatalf("unexpected text fields: %q", textReq.Fields) + } + if textReq.TextStyle.WeightedFontFamily.FontFamily != "Georgia" { + t.Fatalf("unexpected font: %#v", textReq.TextStyle.WeightedFontFamily) + } + if textReq.TextStyle.Bold { + t.Fatalf("bold should be false") + } + + encoded, err := json.Marshal(textReq.TextStyle) + if err != nil { + t.Fatalf("marshal style: %v", err) + } + if !strings.Contains(string(encoded), `"bold":false`) { + t.Fatalf("clearing bold must force-send false, got %s", encoded) + } + + paraReq := reqs[1].UpdateParagraphStyle + if paraReq == nil { + t.Fatalf("missing paragraph request: %#v", reqs[1]) + } + if paraReq.ParagraphStyle.Alignment != "CENTER" || paraReq.ParagraphStyle.LineSpacing != 150 { + t.Fatalf("unexpected paragraph style: %#v", paraReq.ParagraphStyle) + } + if got := paraReq.Range; got.TabId != "t.second" { + t.Fatalf("paragraph range lost tab id: %#v", got) + } +} + +func TestDocsFormatFlagsValidation(t *testing.T) { + if _, err := (DocsFormatFlags{TextColor: "oops"}).buildRequests(1, 2, ""); err == nil { + t.Fatalf("expected invalid color error") + } + if _, err := (DocsFormatFlags{Bold: true, NoBold: true}).buildRequests(1, 2, ""); err == nil { + t.Fatalf("expected conflicting bold flags error") + } + if _, err := (DocsFormatFlags{Alignment: "sideways"}).buildRequests(1, 2, ""); err == nil { + t.Fatalf("expected invalid alignment error") + } +} + +func TestDocsWriteFormatsInsertedRangeOnly(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "body": map[string]any{"content": []any{ + map[string]any{"startIndex": 1, "endIndex": 12}, + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + ctx := newDocsJSONContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", "world", "--append", "--bold", "--font-size", "12"}, ctx, flags); err != nil { + t.Fatalf("write: %v", err) + } + if len(batchRequests) != 1 { + t.Fatalf("expected one batch, got %d", len(batchRequests)) + } + reqs := batchRequests[0] + if len(reqs) != 2 || reqs[0].InsertText == nil || reqs[1].UpdateTextStyle == nil { + t.Fatalf("unexpected requests: %#v", reqs) + } + if got := reqs[0].InsertText.Location.Index; got != 11 { + t.Fatalf("unexpected insert index: %d", got) + } + if got := reqs[1].UpdateTextStyle.Range; got.StartIndex != 11 || got.EndIndex != 16 { + t.Fatalf("format should cover inserted text only, got %#v", got) + } +} + +func TestDocsFormatCmdMatchAll(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docBodyWithText("Alpha Beta Alpha\n")) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + ctx := newDocsJSONContext(t) + flags := &RootFlags{Account: "a@b.com"} + if err := runKong(t, &DocsFormatCmd{}, []string{"doc1", "--match", "Alpha", "--match-all", "--underline", "--bg-color", "#fff"}, ctx, flags); err != nil { + t.Fatalf("format: %v", err) + } + if len(batchRequests) != 1 { + t.Fatalf("expected one batch, got %d", len(batchRequests)) + } + reqs := batchRequests[0] + if len(reqs) != 2 { + t.Fatalf("expected two match requests, got %#v", reqs) + } + if got := reqs[0].UpdateTextStyle.Range; got.StartIndex != 1 || got.EndIndex != 6 { + t.Fatalf("unexpected first match range: %#v", got) + } + if got := reqs[1].UpdateTextStyle.Range; got.StartIndex != 12 || got.EndIndex != 17 { + t.Fatalf("unexpected second match range: %#v", got) + } +}