feat(docs): add rich text formatting commands

This commit is contained in:
Peter Steinberger 2026-05-04 08:28:38 +01:00
parent 7b288cc922
commit b4d6f559c3
No known key found for this signature in database
11 changed files with 608 additions and 13 deletions

View File

@ -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.

View File

@ -1191,9 +1191,13 @@ gog docs update <docId> --text "Only in this tab" --tab "Notes"
gog docs update <docId> --file ./insert.txt --index 25 --pageless
gog docs write <docId> --text "Fresh content"
gog docs write <docId> --text "Rewrite one tab" --tab "Notes"
gog docs write <docId> --text "Important" --bold --text-color "#3366cc"
gog docs write <docId> --file ./body.txt --append --pageless
gog docs write <docId> --file ./body.md --replace --markdown
gog docs write <docId> --file ./body.md --append --markdown
gog docs format <docId> --match "Important" --bold --bg-color "#fff2cc"
gog docs format <docId> --match "todo" --match-all --no-bold --underline
gog docs format <docId> --alignment center --line-spacing 150
gog docs find-replace <docId> "old" "new"
gog docs find-replace <docId> "old" "new" --tab "Notes"
gog docs raw <docId> # Lossless JSON dump of Documents.Get (LLM/scripting)

View File

@ -222,6 +222,7 @@ Generated from `gog schema --json`.
- [`gog docs (doc) edit <docId> <find> <replace> [flags]`](commands/gog-docs-edit.md) - Find and replace text in a Google Doc
- [`gog docs (doc) export (download,dl) <docId> [flags]`](commands/gog-docs-export.md) - Export a Google Doc (pdf|docx|txt|md|html)
- [`gog docs (doc) find-replace <docId> <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 <docId> [flags]`](commands/gog-docs-format.md) - Apply text or paragraph formatting to a Google Doc
- [`gog docs (doc) info (get,show) <docId>`](commands/gog-docs-info.md) - Get Google Doc metadata
- [`gog docs (doc) insert <docId> [<content>] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position
- [`gog docs (doc) list-tabs <docId>`](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc

View File

@ -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

View File

@ -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 <docId> [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`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--italic` | `bool` | | Set italic |
| `-j`<br>`--json`<br>`--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`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--no-italic` | `bool` | | Clear italic |
| `--no-strikethrough`<br>`--no-strike` | `bool` | | Clear strikethrough |
| `--no-underline` | `bool` | | Clear underline |
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--strikethrough`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
## See Also
- [gog docs](gog-docs.md)
- [Command index](README.md)

View File

@ -20,26 +20,40 @@ gog docs (doc) write <docId> [flags]
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--italic` | `bool` | | Set italic |
| `-j`<br>`--json`<br>`--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`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--no-italic` | `bool` | | Clear italic |
| `--no-strikethrough`<br>`--no-strike` | `bool` | | Clear strikethrough |
| `--no-underline` | `bool` | | Clear underline |
| `--pageless` | `bool` | | Set document to pageless mode |
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--strikethrough`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |

View File

@ -27,6 +27,7 @@ gog docs (doc) <command> [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

View File

@ -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"`

View File

@ -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 {

325
internal/cmd/docs_format.go Normal file
View File

@ -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")
}
}

View File

@ -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)
}
}