diff --git a/CHANGELOG.md b/CHANGELOG.md index 808afe9b..d91e77d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [--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 diff --git a/docs/commands/gog-docs-format.md b/docs/commands/gog-docs-format.md index 910a63f7..56767793 100644 --- a/docs/commands/gog-docs-format.md +++ b/docs/commands/gog-docs-format.md @@ -32,6 +32,7 @@ gog docs (doc) format [flags] | `--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) | +| `--heading-level` | `*int` | | Set paragraph named style to HEADING_1..HEADING_6 (shortcut for --named-style=HEADING_N) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `--italic` | `bool` | | Set italic | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | @@ -39,6 +40,7 @@ gog docs (doc) format [flags] | `--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`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--no-italic` | `bool` | | Clear italic | diff --git a/docs/commands/gog-docs-write.md b/docs/commands/gog-docs-write.md index c79d83ad..199f8688 100644 --- a/docs/commands/gog-docs-write.md +++ b/docs/commands/gog-docs-write.md @@ -34,11 +34,13 @@ gog docs (doc) write [flags] | `--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) | +| `--heading-level` | `*int` | | Set paragraph named style to HEADING_1..HEADING_6 (shortcut for --named-style=HEADING_N) | | `-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) | +| `--named-style` | `string` | | Set paragraph named style: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6 | | `--no-bold` | `bool` | | Clear bold | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--no-italic` | `bool` | | Clear italic | diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 4d2cbdb8..ffa7913d 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -37,6 +37,17 @@ gog docs format --match "Action item" --text-color '#b00020' gog docs format --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 --match "Status" --heading-level 2 +gog docs format --match "Overview" --named-style title --alignment center +``` + Use `--match-all` when every occurrence should be formatted. Command page: diff --git a/internal/cmd/docs_format.go b/internal/cmd/docs_format.go index e8b8f249..0d350cf6 100644 --- a/internal/cmd/docs_format.go +++ b/internal/cmd/docs_format.go @@ -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 == "" { @@ -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 @@ -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) { @@ -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 } @@ -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 { diff --git a/internal/cmd/docs_format_headings_test.go b/internal/cmd/docs_format_headings_test.go new file mode 100644 index 00000000..834505ed --- /dev/null +++ b/internal/cmd/docs_format_headings_test.go @@ -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 }