Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Calendar: add --with-zoom / --regenerate-zoom / --remove-zoom that create, regenerate, and remove Zoom meetings and attach the join URL + meeting ID + passcode to the Calendar event description. Google's Calendar API rejects conferenceData writes asserting `conferenceSolution.key.type="addOn"` from non-Workspace-Marketplace OAuth clients, so the description-mode integration is the path that round-trips through Google's storage; trade-off is no native "Join with Zoom" conference card. (#589, #590) — thanks @alexisperumal and @mvanhorn.
- Auth: add gog zoom auth setup / doctor for Zoom S2S OAuth credential storage. (#590) — thanks @mvanhorn.
- Docs: add `gog docs insert-page-break <docId> [--index N | --at-end] [--tab=STRING]` to insert a Google Docs page break directly via `InsertPageBreakRequest` — markdown has no native page-break construct, so this is the only path for multi-page deliverables. Aliases: `page-break`, `pb`. (#604)
- Docs: add `--heading-level N` (1..6 shortcut) and `--named-style NAME` (full enum) to `gog docs format` so existing paragraphs can be promoted to `HEADING_1`..`HEADING_6`, `TITLE`, `SUBTITLE`, or `NORMAL_TEXT`. Both set `paragraphStyle.namedStyleType` on the existing UpdateParagraphStyle request and compose cleanly with `--alignment` / `--line-spacing`. (#605)

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions docs/commands/gog-docs-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ gog docs (doc) format <docId> [flags]
| `--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) |
| `--heading-level` | `*int` | | Set paragraph named style to HEADING_1..HEADING_6 (shortcut for --named-style=HEADING_N) |
| `-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 |
| `--named-style` | `string` | | Set paragraph named style: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6 |
| `--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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/commands/gog-docs-write.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ gog docs (doc) write <docId> [flags]
| `--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) |
| `--heading-level` | `*int` | | Set paragraph named style to HEADING_1..HEADING_6 (shortcut for --named-style=HEADING_N) |
| `-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) |
| `--named-style` | `string` | | Set paragraph named style: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6 |
| `--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 |
Expand Down
11 changes: 11 additions & 0 deletions docs/docs-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ gog docs format <docId> --match "Action item" --text-color '#b00020'
gog docs format <docId> --match Heading --alignment center --line-spacing 120
```

Promote an existing paragraph to a heading or title style with
`--heading-level N` (1..6 shortcut) or `--named-style NAME` (full enum:
`NORMAL_TEXT`, `TITLE`, `SUBTITLE`, `HEADING_1`..`HEADING_6`,
case-insensitive). Both set `paragraphStyle.namedStyleType` on the same
update so they compose with `--alignment` and `--line-spacing`:

```bash
gog docs format <docId> --match "Status" --heading-level 2
gog docs format <docId> --match "Overview" --named-style title --alignment center
```

Use `--match-all` when every occurrence should be formatted.

Command page:
Expand Down
56 changes: 55 additions & 1 deletion internal/cmd/docs_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,22 @@ type DocsFormatFlags struct {
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"`
HeadingLevel *int `name:"heading-level" help:"Set paragraph named style to HEADING_1..HEADING_6 (shortcut for --named-style=HEADING_N)"`
NamedStyle string `name:"named-style" help:"Set paragraph named style: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6"`
}

const (
docsNamedStyleNormalText = "NORMAL_TEXT"
docsNamedStyleTitle = "TITLE"
docsNamedStyleSubtitle = "SUBTITLE"
docsNamedStyleHeading1 = "HEADING_1"
docsNamedStyleHeading2 = "HEADING_2"
docsNamedStyleHeading3 = "HEADING_3"
docsNamedStyleHeading4 = "HEADING_4"
docsNamedStyleHeading5 = "HEADING_5"
docsNamedStyleHeading6 = "HEADING_6"
)

func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
id := strings.TrimSpace(c.DocID)
if id == "" {
Expand Down Expand Up @@ -82,6 +96,8 @@ func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
"no_strike": c.Format.NoStrike,
"alignment": c.Format.Alignment,
"line_spacing": c.Format.LineSpacing,
"heading_level": c.Format.HeadingLevel,
"named_style": c.Format.NamedStyle,
},
}); err != nil {
return err
Expand Down Expand Up @@ -207,7 +223,9 @@ func (f DocsFormatFlags) any() bool {
f.Underline || f.NoUnderline ||
f.Strikethrough || f.NoStrike ||
strings.TrimSpace(f.Alignment) != "" ||
f.LineSpacing != 0
f.LineSpacing != 0 ||
f.HeadingLevel != nil ||
strings.TrimSpace(f.NamedStyle) != ""
}

func (f DocsFormatFlags) buildRequests(start, end int64, tabID string) ([]*docs.Request, error) {
Expand Down Expand Up @@ -321,6 +339,14 @@ func (f DocsFormatFlags) buildParagraphStyleRequest(start, end int64, tabID stri
style.LineSpacing = f.LineSpacing
fields = append(fields, "lineSpacing")
}
namedStyle, err := docsFormatNamedStyle(f.HeadingLevel, f.NamedStyle)
if err != nil {
return nil, false, err
}
if namedStyle != "" {
style.NamedStyleType = namedStyle
fields = append(fields, "namedStyleType")
}
if len(fields) == 0 {
return nil, false, nil
}
Expand All @@ -331,6 +357,34 @@ func (f DocsFormatFlags) buildParagraphStyleRequest(start, end int64, tabID stri
}}, true, nil
}

// docsFormatNamedStyle resolves the named paragraph style requested via
// --heading-level (1..6 shortcut) and/or --named-style (full enum). The two
// are mutually exclusive — supplying both is a usage error. Returns "" when
// neither was supplied.
func docsFormatNamedStyle(headingLevel *int, namedStyle string) (string, error) {
trimmed := strings.ToUpper(strings.TrimSpace(namedStyle))
if headingLevel != nil && trimmed != "" {
return "", usage("--heading-level and --named-style cannot be combined")
}
if headingLevel != nil {
if *headingLevel < 1 || *headingLevel > 6 {
return "", usage("--heading-level must be between 1 and 6")
}
return fmt.Sprintf("HEADING_%d", *headingLevel), nil
}
if trimmed == "" {
return "", nil
}
switch trimmed {
case docsNamedStyleNormalText, docsNamedStyleTitle, docsNamedStyleSubtitle,
docsNamedStyleHeading1, docsNamedStyleHeading2, docsNamedStyleHeading3,
docsNamedStyleHeading4, docsNamedStyleHeading5, docsNamedStyleHeading6:
return trimmed, nil
default:
return "", usage("--named-style must be one of NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6")
}
}

func docsFormatColor(hex, flag string) (*docs.OptionalColor, error) {
r, g, b, ok := parseHexColor(hex)
if !ok {
Expand Down
132 changes: 132 additions & 0 deletions internal/cmd/docs_format_headings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cmd

import (
"strings"
"testing"
)

// Coverage for #605: --heading-level (1-6 shortcut) and --named-style flags
// surface the Docs API paragraphStyle.namedStyleType field on `docs format`
// so existing paragraphs can be restyled into HEADING_1..HEADING_6, TITLE,
// SUBTITLE, or NORMAL_TEXT without rewriting them through the markdown path.

func TestDocsFormatFlags_HeadingLevelEmitsNamedStyleType(t *testing.T) {
cases := []struct {
name string
level int
want string
}{
{"H1", 1, docsNamedStyleHeading1},
{"H2", 2, docsNamedStyleHeading2},
{"H6", 6, docsNamedStyleHeading6},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
reqs, err := (DocsFormatFlags{HeadingLevel: intPtr(tt.level)}).buildRequests(3, 9, "")
if err != nil {
t.Fatalf("buildRequests: %v", err)
}
if len(reqs) != 1 || reqs[0].UpdateParagraphStyle == nil {
t.Fatalf("expected one paragraph-style request, got %#v", reqs)
}
pr := reqs[0].UpdateParagraphStyle
if pr.ParagraphStyle.NamedStyleType != tt.want {
t.Fatalf("NamedStyleType = %q, want %q", pr.ParagraphStyle.NamedStyleType, tt.want)
}
if !strings.Contains(pr.Fields, "namedStyleType") {
t.Fatalf("Fields must include namedStyleType, got %q", pr.Fields)
}
})
}
}

func TestDocsFormatFlags_NamedStyleAcceptsAllValidEnums(t *testing.T) {
for _, ns := range []string{
docsNamedStyleNormalText,
docsNamedStyleTitle,
docsNamedStyleSubtitle,
docsNamedStyleHeading1,
docsNamedStyleHeading2,
docsNamedStyleHeading3,
docsNamedStyleHeading4,
docsNamedStyleHeading5,
docsNamedStyleHeading6,
} {
t.Run(ns, func(t *testing.T) {
reqs, err := (DocsFormatFlags{NamedStyle: ns}).buildRequests(1, 2, "")
if err != nil {
t.Fatalf("buildRequests(%q): %v", ns, err)
}
if got := reqs[0].UpdateParagraphStyle.ParagraphStyle.NamedStyleType; got != ns {
t.Fatalf("NamedStyleType = %q, want %q", got, ns)
}
})
}
}

func TestDocsFormatFlags_NamedStyleIsCaseInsensitive(t *testing.T) {
reqs, err := (DocsFormatFlags{NamedStyle: "heading_3"}).buildRequests(1, 2, "")
if err != nil {
t.Fatalf("buildRequests: %v", err)
}
if got := reqs[0].UpdateParagraphStyle.ParagraphStyle.NamedStyleType; got != docsNamedStyleHeading3 {
t.Fatalf("NamedStyleType = %q, want %s", got, docsNamedStyleHeading3)
}
}

func TestDocsFormatFlags_HeadingLevelAndNamedStyleMutuallyExclusive(t *testing.T) {
_, err := (DocsFormatFlags{HeadingLevel: intPtr(1), NamedStyle: "TITLE"}).buildRequests(1, 2, "")
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
}

func TestDocsFormatFlags_HeadingLevelOutOfRangeRejected(t *testing.T) {
for _, lvl := range []int{-1, 0, 7, 99} {
_, err := (DocsFormatFlags{HeadingLevel: intPtr(lvl)}).buildRequests(1, 2, "")
if err == nil || !strings.Contains(err.Error(), "--heading-level must be between 1 and 6") {
t.Fatalf("level %d: expected range error, got %v", lvl, err)
}
}
}

func TestDocsFormatFlags_UnknownNamedStyleRejected(t *testing.T) {
_, err := (DocsFormatFlags{NamedStyle: "BANNER"}).buildRequests(1, 2, "")
if err == nil || !strings.Contains(err.Error(), "--named-style must be one of") {
t.Fatalf("expected named-style enum error, got %v", err)
}
}

func TestDocsFormatFlags_HeadingComposesWithAlignment(t *testing.T) {
reqs, err := (DocsFormatFlags{HeadingLevel: intPtr(1), Alignment: "center"}).buildRequests(3, 9, "t.tab")
if err != nil {
t.Fatalf("buildRequests: %v", err)
}
if len(reqs) != 1 {
t.Fatalf("expected one paragraph request combining alignment + heading, got %d", len(reqs))
}
pr := reqs[0].UpdateParagraphStyle
if pr.ParagraphStyle.NamedStyleType != docsNamedStyleHeading1 || pr.ParagraphStyle.Alignment != "CENTER" {
t.Fatalf("unexpected style: %#v", pr.ParagraphStyle)
}
if !strings.Contains(pr.Fields, "namedStyleType") || !strings.Contains(pr.Fields, "alignment") {
t.Fatalf("Fields missing combined attrs: %q", pr.Fields)
}
if pr.Range.TabId != "t.tab" {
t.Fatalf("range lost tab: %#v", pr.Range)
}
}

func TestDocsFormatFlags_AnyDetectsHeadingFlags(t *testing.T) {
if !(DocsFormatFlags{HeadingLevel: intPtr(2)}).any() {
t.Fatalf("any() should be true when HeadingLevel is set")
}
if !(DocsFormatFlags{NamedStyle: docsNamedStyleTitle}).any() {
t.Fatalf("any() should be true when NamedStyle is set")
}
if (DocsFormatFlags{}).any() {
t.Fatalf("any() should be false when nothing is set")
}
}

func intPtr(v int) *int { return &v }
Loading