From 90b3ef9872023b6dfbc79f1628b4a42e5460dead Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:11:56 -0400 Subject: [PATCH 1/4] feat(docs): add insert-page-break primitive Closes #604. Surfaces the Docs API InsertPageBreakRequest as a standalone CLI subcommand. Markdown has no native page-break construct that the markdown writer can translate, and docs format only restyles existing content, so this is the first structural-element insert primitive on the docs side. Usage: gog docs insert-page-break [--index N | --at-end] [--tab=STRING] Behaviour: - Omitted --index and --at-end both insert at end-of-doc (matches the existing docs write --append default; uses the same docsTargetEndIndexAndTabID + docsAppendIndex helpers). - --index N (>=1) inserts at the explicit body index; --at-end is explicit form of the default. The two are mutually exclusive. - --tab supports both --index and --at-end paths. Aliases: `page-break`, `pb`. Tests cover explicit index, --at-end, default end-of-doc, --tab, mutual exclusion of --at-end + --index, and the negative-index guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cmd/docs.go | 1 + internal/cmd/docs_insert_page_break.go | 114 ++++++++++ internal/cmd/docs_insert_page_break_test.go | 222 ++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 internal/cmd/docs_insert_page_break.go create mode 100644 internal/cmd/docs_insert_page_break_test.go diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 36095cba..513a60b6 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -31,6 +31,7 @@ type DocsCmd struct { ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` + InsertPageBreak DocsInsertPageBreakCmd `cmd:"" name:"insert-page-break" aliases:"page-break,pb" help:"Insert a page break at a specific position (or end-of-doc with --at-end)"` Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` 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"` diff --git a/internal/cmd/docs_insert_page_break.go b/internal/cmd/docs_insert_page_break.go new file mode 100644 index 00000000..c0f52018 --- /dev/null +++ b/internal/cmd/docs_insert_page_break.go @@ -0,0 +1,114 @@ +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" +) + +// DocsInsertPageBreakCmd inserts a page break at a specific character index in +// a Google Doc (or at the end of the body/tab when --at-end is supplied, or +// --index is omitted). Surfaces the Docs API InsertPageBreakRequest directly, +// since markdown has no native page-break construct that the markdown writer +// could translate. +type DocsInsertPageBreakCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Index int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` + AtEnd bool `name:"at-end" help:"Insert at end-of-doc/tab (mutually exclusive with --index)"` + 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"` +} + +func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + if c.AtEnd && c.Index != 0 { + return usage("--at-end and --index are mutually exclusive") + } + if c.Index < 0 { + return usage("--index must be >= 1 (index 0 is reserved)") + } + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + + resolveEnd := c.AtEnd || c.Index == 0 + + dryRunPayload := map[string]any{ + "documentId": docID, + "tab": c.Tab, + } + if resolveEnd { + dryRunPayload["atIndex"] = "end" + } else { + dryRunPayload["atIndex"] = c.Index + } + if dryRunErr := dryRunExit(ctx, flags, "docs.insert-page-break", dryRunPayload); dryRunErr != nil { + return dryRunErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + insertIndex := c.Index + if resolveEnd { + endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if endErr != nil { + return endErr + } + c.Tab = tabID + insertIndex = docsAppendIndex(endIndex) + } else if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID + } + + result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ + InsertPageBreak: &docs.InsertPageBreakRequest{ + Location: &docs.Location{ + Index: insertIndex, + TabId: c.Tab, + }, + }, + }}, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("inserting page break: %w", err) + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": result.DocumentId, + "atIndex": insertIndex, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + u.Out().Linef("documentId\t%s", result.DocumentId) + u.Out().Linef("atIndex\t%d", insertIndex) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} diff --git a/internal/cmd/docs_insert_page_break_test.go b/internal/cmd/docs_insert_page_break_test.go new file mode 100644 index 00000000..44c33dfe --- /dev/null +++ b/internal/cmd/docs_insert_page_break_test.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" +) + +func pageBreakDocWithEndIndex(end int64) map[string]any { + return map[string]any{ + "documentId": "doc1", + "body": map[string]any{ + "content": []any{ + map[string]any{"startIndex": 0, "endIndex": end}, + }, + }, + } +} + +func TestDocsInsertPageBreakCmd_ExplicitIndex(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pageBreakDocWithEndIndex(50)) + 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: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--index", "7"}, ctx, flags); err != nil { + t.Fatalf("insert-page-break: %v", err) + } + + if getCalls != 0 { + t.Fatalf("explicit --index should not GET the doc, got %d GET calls", getCalls) + } + if len(batchRequests) != 1 || len(batchRequests[0]) != 1 { + t.Fatalf("unexpected requests: %#v", batchRequests) + } + pb := batchRequests[0][0].InsertPageBreak + if pb == nil { + t.Fatalf("expected InsertPageBreak, got %#v", batchRequests[0][0]) + } + if pb.Location == nil || pb.Location.Index != 7 { + t.Fatalf("expected page break at index 7, got %#v", pb.Location) + } +} + +func TestDocsInsertPageBreakCmd_DefaultsToEndOfDoc(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pageBreakDocWithEndIndex(42)) + 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: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1"}, ctx, flags); err != nil { + t.Fatalf("insert-page-break: %v", err) + } + + if getCalls != 1 { + t.Fatalf("expected 1 GET to resolve end, got %d", getCalls) + } + pb := batchRequests[0][0].InsertPageBreak + if pb == nil || pb.Location == nil { + t.Fatalf("expected InsertPageBreak with Location, got %#v", batchRequests[0][0]) + } + if pb.Location.Index != 41 { + t.Fatalf("expected page break at end-1 (41), got %d", pb.Location.Index) + } +} + +func TestDocsInsertPageBreakCmd_AtEndFlag(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pageBreakDocWithEndIndex(100)) + 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: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--at-end"}, ctx, flags); err != nil { + t.Fatalf("insert-page-break: %v", err) + } + + if getCalls != 1 { + t.Fatalf("expected 1 GET to resolve end, got %d", getCalls) + } + if got := batchRequests[0][0].InsertPageBreak.Location.Index; got != 99 { + t.Fatalf("expected end-1 (99), got %d", got) + } +} + +func TestDocsInsertPageBreakCmd_AtEndAndIndexRejected(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--at-end", "--index", "5"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} + +func TestDocsInsertPageBreakCmd_NegativeIndexRejected(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--index=-1"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 1") { + t.Fatalf("expected --index validation error, got %v", err) + } +} + +func TestDocsInsertPageBreakCmd_WithTab(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.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tabsDocWithEndIndex()) + 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: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--index", "5", "--tab", "Second"}, ctx, flags); err != nil { + t.Fatalf("insert-page-break: %v", err) + } + + pb := batchRequests[0][0].InsertPageBreak + if pb == nil || pb.Location == nil { + t.Fatalf("expected InsertPageBreak with Location, got %#v", batchRequests[0][0]) + } + if pb.Location.TabId != "t.second" || pb.Location.Index != 5 { + t.Fatalf("unexpected location: %#v", pb.Location) + } +} From 50ce29b76f0895500eac7ed4c23f53e96d462ffb Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:12:17 -0400 Subject: [PATCH 2/4] style(docs): gofmt re-alignment of DocsCmd struct tags Realigned by gofmt after adding the InsertPageBreak field. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cmd/docs.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 513a60b6..a18d7268 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -18,29 +18,29 @@ import ( var newDocsService = googleapi.NewDocs type DocsCmd struct { - Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt|md|html)"` - Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"` - Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"` - Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"` - Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"` - Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` - Tabs DocsTabsCmd `cmd:"" name:"tabs" help:"Manage Google Doc tabs"` - AddTab DocsAddTabCmd `cmd:"" name:"add-tab" help:"Add a tab to a Google Doc"` - RenameTab DocsRenameTabCmd `cmd:"" name:"rename-tab" help:"Rename a tab in a Google Doc"` - DeleteTab DocsDeleteTabCmd `cmd:"" name:"delete-tab" help:"Delete a tab from a Google Doc"` - ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` - Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` - Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` + Export DocsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Doc (pdf|docx|txt|md|html)"` + Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"` + Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"` + Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"` + Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"` + Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` + Tabs DocsTabsCmd `cmd:"" name:"tabs" help:"Manage Google Doc tabs"` + AddTab DocsAddTabCmd `cmd:"" name:"add-tab" help:"Add a tab to a Google Doc"` + RenameTab DocsRenameTabCmd `cmd:"" name:"rename-tab" help:"Rename a tab in a Google Doc"` + DeleteTab DocsDeleteTabCmd `cmd:"" name:"delete-tab" help:"Delete a tab from a Google Doc"` + ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` + Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` + Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` InsertPageBreak DocsInsertPageBreakCmd `cmd:"" name:"insert-page-break" aliases:"page-break,pb" help:"Insert a page break at a specific position (or end-of-doc with --at-end)"` - Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` - 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"` - Raw DocsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)"` + Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` + 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"` + Raw DocsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)"` } type DocsTabsCmd struct { From ebd545c3f3eb8f8c564f6ab53992c5bf7c18330b Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:39:15 -0400 Subject: [PATCH 3/4] docs: changelog + regen + topic-doc for #604 insert-page-break Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + README.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 3 +- docs/commands/gog-docs-insert-page-break.md | 46 +++++++++++++++++++++ docs/commands/gog-docs.md | 1 + docs/docs-editing.md | 18 ++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/commands/gog-docs-insert-page-break.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4d49c2..808afe9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Docs: add `VISION.md` with project fit, discussion, and live-test merge guidance. - 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) ### Fixed diff --git a/README.md b/README.md index 9ad334e0..cb8781a3 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ Docs: [Google Docs editing](docs/docs-editing.md), ```bash gog docs write --append --markdown --text '## Status' gog docs format --match Status --bold --font-size 18 +gog docs insert-page-break --at-end gog docs add-tab --title "Notes" gog docs tabs add --title "Notes" gog docs find-replace old new --tab "Notes" --dry-run diff --git a/docs/commands.generated.md b/docs/commands.generated.md index f12cda04..33934568 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -238,6 +238,7 @@ Generated from `gog schema --json`. - [`gog docs (doc) format [flags]`](commands/gog-docs-format.md) - Apply text or paragraph formatting to a Google Doc - [`gog docs (doc) info (get,show) `](commands/gog-docs-info.md) - Get Google Doc metadata - [`gog docs (doc) insert [] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position + - [`gog docs (doc) insert-page-break (page-break,pb) [flags]`](commands/gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [`gog docs (doc) list-tabs `](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc - [`gog docs (doc) raw [flags]`](commands/gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [`gog docs (doc) rename-tab [flags]`](commands/gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/commands/README.md b/docs/commands/README.md index b512ffcd..d0734147 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 564. +Generated pages: 565. ## Top-level Commands @@ -289,6 +289,7 @@ Generated pages: 564. - [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 insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc - [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/commands/gog-docs-insert-page-break.md b/docs/commands/gog-docs-insert-page-break.md new file mode 100644 index 00000000..116bb7df --- /dev/null +++ b/docs/commands/gog-docs-insert-page-break.md @@ -0,0 +1,46 @@ +# `gog docs insert-page-break` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Insert a page break at a specific position (or end-of-doc with --at-end) + +## Usage + +```bash +gog docs (doc) insert-page-break (page-break,pb) [flags] +``` + +## Parent + +- [gog docs](gog-docs.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--at-end` | `bool` | | Insert at end-of-doc/tab (mutually exclusive with --index) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--index` | `int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog docs](gog-docs.md) +- [Command index](README.md) diff --git a/docs/commands/gog-docs.md b/docs/commands/gog-docs.md index b90ad133..7fa8b6b5 100644 --- a/docs/commands/gog-docs.md +++ b/docs/commands/gog-docs.md @@ -30,6 +30,7 @@ gog docs (doc) [flags] - [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 insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc - [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 21e4434c..4d2cbdb8 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -43,6 +43,24 @@ Command page: - [`gog docs format`](commands/gog-docs-format.md) +## Page Breaks + +Markdown has no native page-break construct, so multi-page deliverables need a +direct Docs API call. Insert a page break at a specific index or append one at +end-of-doc: + +```bash +gog docs insert-page-break --at-end +gog docs insert-page-break --index 250 --tab "Notes" +``` + +`--index` and `--at-end` are mutually exclusive; omit both to default to +end-of-doc. Aliases: `page-break`, `pb`. + +Command page: + +- [`gog docs insert-page-break`](commands/gog-docs-insert-page-break.md) + ## Tabs Manage Google Docs tabs: From 4021e5c6e3f2973dff0fe6b95a20a6758fb518da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 21:19:46 +0100 Subject: [PATCH 4/4] fix(docs): reject zero page-break insert index --- docs/commands/gog-docs-insert-page-break.md | 2 +- internal/cmd/docs_insert_page_break.go | 25 ++++++++++++--------- internal/cmd/docs_insert_page_break_test.go | 9 ++++++++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/commands/gog-docs-insert-page-break.md b/docs/commands/gog-docs-insert-page-break.md index 116bb7df..2498f53a 100644 --- a/docs/commands/gog-docs-insert-page-break.md +++ b/docs/commands/gog-docs-insert-page-break.md @@ -29,7 +29,7 @@ gog docs (doc) insert-page-break (page-break,pb) [flags] | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | -| `--index` | `int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | +| `--index` | `*int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | diff --git a/internal/cmd/docs_insert_page_break.go b/internal/cmd/docs_insert_page_break.go index c0f52018..35071b93 100644 --- a/internal/cmd/docs_insert_page_break.go +++ b/internal/cmd/docs_insert_page_break.go @@ -19,7 +19,7 @@ import ( // could translate. type DocsInsertPageBreakCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` - Index int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` + Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` AtEnd bool `name:"at-end" help:"Insert at end-of-doc/tab (mutually exclusive with --index)"` 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"` @@ -31,10 +31,10 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro if docID == "" { return usage("empty docId") } - if c.AtEnd && c.Index != 0 { + if c.AtEnd && c.Index != nil { return usage("--at-end and --index are mutually exclusive") } - if c.Index < 0 { + if c.Index != nil && *c.Index < 1 { return usage("--index must be >= 1 (index 0 is reserved)") } @@ -44,7 +44,7 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro } c.Tab = tab - resolveEnd := c.AtEnd || c.Index == 0 + resolveEnd := c.AtEnd || c.Index == nil dryRunPayload := map[string]any{ "documentId": docID, @@ -53,7 +53,7 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro if resolveEnd { dryRunPayload["atIndex"] = "end" } else { - dryRunPayload["atIndex"] = c.Index + dryRunPayload["atIndex"] = *c.Index } if dryRunErr := dryRunExit(ctx, flags, "docs.insert-page-break", dryRunPayload); dryRunErr != nil { return dryRunErr @@ -64,7 +64,7 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro return err } - insertIndex := c.Index + var insertIndex int64 if resolveEnd { endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) if endErr != nil { @@ -72,12 +72,15 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro } c.Tab = tabID insertIndex = docsAppendIndex(endIndex) - } else if c.Tab != "" { - tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) - if tabErr != nil { - return tabErr + } else { + insertIndex = *c.Index + if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID } - c.Tab = tabID } result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ diff --git a/internal/cmd/docs_insert_page_break_test.go b/internal/cmd/docs_insert_page_break_test.go index 44c33dfe..6b3454fb 100644 --- a/internal/cmd/docs_insert_page_break_test.go +++ b/internal/cmd/docs_insert_page_break_test.go @@ -179,6 +179,15 @@ func TestDocsInsertPageBreakCmd_NegativeIndexRejected(t *testing.T) { } } +func TestDocsInsertPageBreakCmd_ZeroIndexRejected(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + err := runKong(t, &DocsInsertPageBreakCmd{}, []string{"doc1", "--index", "0"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 1") { + t.Fatalf("expected --index validation error, got %v", err) + } +} + func TestDocsInsertPageBreakCmd_WithTab(t *testing.T) { origDocs := newDocsService t.Cleanup(func() { newDocsService = origDocs })