feat(docs): add rich text formatting commands
This commit is contained in:
parent
7b288cc922
commit
b4d6f559c3
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
60
docs/commands/gog-docs-format.md
Normal file
60
docs/commands/gog-docs-format.md
Normal 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)
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
325
internal/cmd/docs_format.go
Normal 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")
|
||||
}
|
||||
}
|
||||
173
internal/cmd/docs_format_test.go
Normal file
173
internal/cmd/docs_format_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user