diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 69ad69c..56c23e8 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -71,7 +71,7 @@ Generated from `gog schema --json`. - `gog calendar (cal) create-calendar (new-calendar) [flags]` - Create a new secondary calendar - `gog calendar (cal) delete (rm,del,remove) [flags]` - Delete an event - `gog calendar (cal) event (get,info,show) ` - Get event -- `gog calendar (cal) events (list,ls) [] [flags]` - List events from a calendar or all calendars +- `gog calendar (cal) events (list,ls) [ ...] [flags]` - List events from a calendar or all calendars - `gog calendar (cal) focus-time (focus) --from=STRING --to=STRING [] [flags]` - Create a Focus Time block - `gog calendar (cal) freebusy [] [flags]` - Get free/busy - `gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [] [flags]` - Create an Out of Office event @@ -202,6 +202,7 @@ Generated from `gog schema --json`. - `gog contacts (contact) search ... [flags]` - Search contacts by name/email/phone - `gog contacts (contact) update (edit,set) [flags]` - Update a contact - `gog docs (doc) [flags]` - Google Docs (export via Drive) +- `gog docs (doc) add-tab [flags]` - Add a tab to a Google Doc - `gog docs (doc) cat (text,read) [flags]` - Print a Google Doc as plain text - `gog docs (doc) clear ` - Clear all content from a Google Doc - `gog docs (doc) comments ` - Manage comments on files @@ -214,12 +215,14 @@ Generated from `gog schema --json`. - `gog docs (doc) copy (cp,duplicate) [flags]` - Copy a Google Doc - `gog docs (doc) create (add,new) <title> [flags]` - Create a Google Doc - `gog docs (doc) delete --start=INT-64 --end=INT-64 <docId> [flags]` - Delete text range from document +- `gog docs (doc) delete-tab <docId> [flags]` - Delete a tab from a Google Doc - `gog docs (doc) edit <docId> <find> <replace> [flags]` - Find and replace text in a Google Doc - `gog docs (doc) export (download,dl) <docId> [flags]` - Export a Google Doc (pdf|docx|txt|md|html) - `gog docs (doc) find-replace <docId> <find> [<replace>] [flags]` - Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence. - `gog docs (doc) info (get,show) <docId>` - Get Google Doc metadata - `gog docs (doc) insert <docId> [<content>] [flags]` - Insert text at a specific position - `gog docs (doc) list-tabs <docId>` - List all tabs in a Google Doc +- `gog docs (doc) rename-tab <docId> [flags]` - Rename a tab in a Google Doc - `gog docs (doc) sed <docId> [<expression>] [flags]` - Regex find/replace (sed-style: s/pattern/replacement/g) - `gog docs (doc) structure (struct) <docId> [flags]` - Show document structure with numbered paragraphs - `gog docs (doc) update <docId> [flags]` - Insert text at a specific index in a Google Doc diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 05441b5..9a2780b 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -24,6 +24,9 @@ type DocsCmd struct { 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"` + 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"` @@ -221,7 +224,7 @@ func (c *DocsCreateCmd) insertImages(ctx context.Context, account string, docID if err != nil { return err } - return insertImagesIntoDocs(ctx, svc, docID, images) + return insertImagesIntoDocs(ctx, svc, docID, images, "") } type DocsCopyCmd struct { diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 2daa657..e02d6e1 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -187,8 +187,10 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI if !c.Replace { return usage("--markdown requires --replace or --append") } + // Tab support for markdown replace is limited because Drive's markdown + // converter doesn't support tab-specific updates, so we skip tab support here. if c.Tab != "" { - return usage("--markdown cannot be combined with --tab") + return usage("--markdown with --replace does not support --tab (Drive's markdown converter operates on entire documents)") } cleaned, images := extractMarkdownImages(content) @@ -217,8 +219,8 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI } } if len(images) > 0 { - if err := insertImagesIntoDocs(ctx, docsSvc, docID, images); err != nil { - cleanupDocsImagePlaceholders(ctx, docsSvc, docID, images) + if err := insertImagesIntoDocs(ctx, docsSvc, docID, images, ""); err != nil { + cleanupDocsImagePlaceholders(ctx, docsSvc, docID, images, "") return fmt.Errorf("insert images: %w", err) } } @@ -254,22 +256,19 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI } func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error { - if c.Tab != "" { - return usage("--markdown cannot be combined with --tab") - } - svc, err := requireDocsService(ctx, flags) if err != nil { return err } - endIndex, _, err := docsTargetEndIndexAndTabID(ctx, svc, docID, "") + endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) if err != nil { return err } + c.Tab = tabID insertIndex := docsAppendIndex(endIndex) - requestCount, inserted, err := insertDocsMarkdownAt(ctx, svc, docID, insertIndex, content) + requestCount, inserted, err := insertDocsMarkdownAt(ctx, svc, docID, insertIndex, content, c.Tab) if err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) @@ -626,23 +625,24 @@ func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error { } c.Tab = tab - if c.Tab != "" && format == docsContentFormatMarkdown { - return usage("--tab is not yet supported with --format markdown") - } - svc, err := requireDocsService(ctx, flags) if err != nil { return err } - if !c.First && format == docsContentFormatPlain { - if c.Tab != "" { - tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) - if tabErr != nil { - return tabErr - } - c.Tab = tabID + if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr } + c.Tab = tabID + } + + if flags != nil && flags.DryRun { + return c.runDryRun(ctx, u, svc, docID, replaceText, format) + } + + if !c.First && format == docsContentFormatPlain { return c.runReplaceAll(ctx, u, svc, docID, replaceText) } @@ -708,6 +708,41 @@ func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } +func (c *DocsFindReplaceCmd) runDryRun(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText, format string) error { + loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = loaded.tabID + + matches := findTextMatches(loaded.target, c.Find, c.MatchCase) + replacements := len(matches) + if c.First && replacements > 1 { + replacements = 1 + } + remaining := len(matches) - replacements + + payload := map[string]any{ + "documentId": docID, + "find": c.Find, + "replace": replaceText, + "format": format, + "first": c.First, + "replacements": replacements, + "remaining": remaining, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + if err := dryRunExit(ctx, &RootFlags{DryRun: true}, "docs.find-replace", payload); err != nil { + return err + } + if !outfmt.IsJSON(ctx) { + u.Out().Printf("matches\t%d", len(matches)) + } + return nil +} + func (c *DocsFindReplaceCmd) runReplaceAll(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText string) error { documentID, replacements, err := runDocsReplaceAll(ctx, svc, docID, c.Find, replaceText, c.MatchCase, c.Tab) if err != nil { @@ -742,7 +777,7 @@ func (c *DocsFindReplaceCmd) runPlain(ctx context.Context, svc *docs.Service, do } func (c *DocsFindReplaceCmd) runMarkdown(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { - return replaceDocsMarkdownRange(ctx, svc, doc, startIdx, endIdx, replaceText) + return replaceDocsMarkdownRange(ctx, svc, doc, startIdx, endIdx, replaceText, c.Tab) } func (c *DocsFindReplaceCmd) printFirstResult(ctx context.Context, u *ui.UI, docID, replaceText string, replacements, total int) error { diff --git a/internal/cmd/docs_find_replace_test.go b/internal/cmd/docs_find_replace_test.go index bc5bd21..3c2c3da 100644 --- a/internal/cmd/docs_find_replace_test.go +++ b/internal/cmd/docs_find_replace_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "net/http" "os" "strings" @@ -54,6 +55,42 @@ func TestDocsFindReplace_PlainText(t *testing.T) { } } +func TestDocsFindReplace_FirstEmptyReplacementDeletesOnly(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var got docs.BatchUpdateDocumentRequest + docSvc, cleanup := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + _ = json.NewEncoder(w).Encode(docBodyWithText("delete-me stays")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + _ = 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"} + cmd := &DocsFindReplaceCmd{} + if err := runKong(t, cmd, []string{"doc1", "delete-me", "", "--first"}, newDocsCmdContext(t), flags); err != nil { + t.Fatalf("docs find-replace empty replacement: %v", err) + } + + if len(got.Requests) != 1 { + t.Fatalf("expected delete-only request, got %d requests", len(got.Requests)) + } + if got.Requests[0].DeleteContentRange == nil { + t.Fatal("expected DeleteContentRange") + } +} + func TestDocsFindReplace_PlainTextMatchCase(t *testing.T) { origDocs := newDocsService t.Cleanup(func() { newDocsService = origDocs }) @@ -117,6 +154,93 @@ func TestDocsFindReplace_ZeroOccurrences(t *testing.T) { } } +func TestDocsFindReplace_DryRunCountsMatchesWithoutMutation(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + docSvc, cleanup := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + _ = json.NewEncoder(w).Encode(docBodyWithText("Draft and draft and Draft")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"): + t.Fatalf("dry-run must not call batchUpdate") + 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", DryRun: true} + cmd := &DocsFindReplaceCmd{} + out := captureStdout(t, func() { + err := runKong(t, cmd, []string{"doc1", "draft", "final"}, ctx, flags) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("expected dry-run exit 0, got: %v", err) + } + }) + + var got struct { + DryRun bool `json:"dry_run"` + Request struct { + Replacements int `json:"replacements"` + Remaining int `json:"remaining"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal: %v\noutput=%q", err, out) + } + if !got.DryRun || got.Request.Replacements != 3 || got.Request.Remaining != 0 { + t.Fatalf("unexpected dry-run payload: %#v", got) + } +} + +func TestDocsFindReplace_DryRunFirstCountsOnlyOneReplacement(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + docSvc, cleanup := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + _ = json.NewEncoder(w).Encode(docBodyWithText("needle and needle")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"): + t.Fatalf("dry-run must not call batchUpdate") + 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", DryRun: true} + cmd := &DocsFindReplaceCmd{} + out := captureStdout(t, func() { + err := runKong(t, cmd, []string{"doc1", "needle", "thread", "--first"}, ctx, flags) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("expected dry-run exit 0, got: %v", err) + } + }) + + var got struct { + Request struct { + Replacements int `json:"replacements"` + Remaining int `json:"remaining"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal: %v\noutput=%q", err, out) + } + if got.Request.Replacements != 1 || got.Request.Remaining != 1 { + t.Fatalf("unexpected dry-run counts: %#v", got.Request) + } +} + func TestDocsFindReplace_ContentFile(t *testing.T) { origDocs := newDocsService t.Cleanup(func() { newDocsService = origDocs }) diff --git a/internal/cmd/docs_formatter.go b/internal/cmd/docs_formatter.go index 079cc4b..a77f395 100644 --- a/internal/cmd/docs_formatter.go +++ b/internal/cmd/docs_formatter.go @@ -19,7 +19,7 @@ type TableData struct { // MarkdownToDocsRequests converts parsed markdown elements to Google Docs batch // update requests. baseIndex is the insertion location in the document. // Returns: requests, plainText, tableData (for native table insertion) -func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*docs.Request, string, []TableData) { +func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64, tabID string) ([]*docs.Request, string, []TableData) { var requests []*docs.Request var plainText strings.Builder var tables []TableData @@ -59,6 +59,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc Range: &docs.Range{ StartIndex: startOffset, EndIndex: charOffset, + TabId: tabID, }, ParagraphStyle: &docs.ParagraphStyle{ NamedStyleType: headingStyle, @@ -69,7 +70,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc // Apply inline text styles for _, style := range styles { - textStyleReq := buildTextStyleRequest(style, startOffset) + textStyleReq := buildTextStyleRequest(style, startOffset, tabID) if textStyleReq != nil { if debugMarkdown { fmt.Printf(" Style request: [%d, %d]\n", @@ -92,6 +93,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc Range: &docs.Range{ StartIndex: startOffset, EndIndex: charOffset, + TabId: tabID, }, TextStyle: &docs.TextStyle{ WeightedFontFamily: &docs.WeightedFontFamily{ @@ -131,6 +133,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc Range: &docs.Range{ StartIndex: startOffset, EndIndex: charOffset, + TabId: tabID, }, ParagraphStyle: &docs.ParagraphStyle{ IndentStart: &docs.Dimension{ @@ -144,7 +147,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc // Apply inline text styles for _, style := range styles { - textStyleReq := buildTextStyleRequest(style, startOffset) + textStyleReq := buildTextStyleRequest(style, startOffset, tabID) if textStyleReq != nil { if debugMarkdown { fmt.Printf(" Style request: [%d, %d] (base=%d, style=[%d,%d])\n", @@ -177,7 +180,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc // Apply inline text styles (offset by prefix length) for _, style := range styles { - textStyleReq := buildTextStyleRequest(style, startOffset+prefixLen) + textStyleReq := buildTextStyleRequest(style, startOffset+prefixLen, tabID) if textStyleReq != nil { requests = append(requests, textStyleReq) } @@ -212,7 +215,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc // Apply inline text styles for _, style := range styles { - textStyleReq := buildTextStyleRequest(style, startOffset) + textStyleReq := buildTextStyleRequest(style, startOffset, tabID) if textStyleReq != nil { if debugMarkdown { fmt.Printf(" Style request: [%d, %d]\n", @@ -268,7 +271,7 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*doc } // buildTextStyleRequest creates a text style update request from a TextStyle -func buildTextStyleRequest(style TextStyle, baseOffset int64) *docs.Request { +func buildTextStyleRequest(style TextStyle, baseOffset int64, tabID string) *docs.Request { // Validate indices if style.Start < 0 || style.End < 0 || style.End <= style.Start { return nil @@ -308,6 +311,7 @@ func buildTextStyleRequest(style TextStyle, baseOffset int64) *docs.Request { Range: &docs.Range{ StartIndex: baseOffset + int64(style.Start), EndIndex: baseOffset + int64(style.End), + TabId: tabID, }, TextStyle: textStyle, Fields: strings.Join(fields, ","), diff --git a/internal/cmd/docs_formatter_test.go b/internal/cmd/docs_formatter_test.go index a431022..3f469c3 100644 --- a/internal/cmd/docs_formatter_test.go +++ b/internal/cmd/docs_formatter_test.go @@ -4,7 +4,7 @@ import "testing" func TestMarkdownToDocsRequests_BaseIndex(t *testing.T) { elements := []MarkdownElement{{Type: MDParagraph, Content: "**bold**"}} - requests, text, tables := MarkdownToDocsRequests(elements, 42) + requests, text, tables := MarkdownToDocsRequests(elements, 42, "") if text != "bold\n" { t.Fatalf("unexpected text: %q", text) @@ -27,7 +27,7 @@ func TestMarkdownToDocsRequests_TableStartIndexUsesBase(t *testing.T) { {Type: MDParagraph, Content: "A"}, {Type: MDTable, TableCells: [][]string{{"h1", "h2"}, {"v1", "v2"}}}, } - _, text, tables := MarkdownToDocsRequests(elements, 10) + _, text, tables := MarkdownToDocsRequests(elements, 10, "") if text != "A\n\n" { t.Fatalf("unexpected text: %q", text) diff --git a/internal/cmd/docs_import.go b/internal/cmd/docs_import.go index 3de0464..90d3bda 100644 --- a/internal/cmd/docs_import.go +++ b/internal/cmd/docs_import.go @@ -217,7 +217,7 @@ func searchParagraph(para *docs.Paragraph, placeholders []string, result map[str // insertImagesIntoDocs reads back a Google Doc to find <<IMG_token_N>> placeholders, // resolves image URLs (remote URLs used directly; local files are rejected), // and replaces the placeholders with inline images via BatchUpdate. -func insertImagesIntoDocs(ctx context.Context, svc *docs.Service, docID string, images []markdownImage) error { +func insertImagesIntoDocs(ctx context.Context, svc *docs.Service, docID string, images []markdownImage, tabID string) error { // Read back the document to find placeholder positions. doc, err := svc.Documents.Get(docID).Context(ctx).Do() if err != nil { @@ -244,7 +244,7 @@ func insertImagesIntoDocs(ctx context.Context, svc *docs.Service, docID string, return fmt.Errorf("local markdown image %q cannot be inserted automatically; Google Docs image insertion requires a public HTTPS image URL, so upload the image to a public host and use that URL", img.originalRef) } - reqs := buildImageInsertRequests(placeholders, images, imageURLs) + reqs := buildImageInsertRequests(placeholders, images, imageURLs, tabID) if len(reqs) == 0 { return nil } @@ -315,7 +315,7 @@ const defaultImageMaxWidthPt = 468.0 // buildImageInsertRequests creates the Docs API batch update requests to replace // placeholder text with inline images. Requests are ordered in reverse index order // so earlier positions are not invalidated as the document is modified. -func buildImageInsertRequests(placeholders map[string]docRange, images []markdownImage, imageURLs map[int]string) []*docs.Request { +func buildImageInsertRequests(placeholders map[string]docRange, images []markdownImage, imageURLs map[int]string, tabID string) []*docs.Request { // Collect entries sorted by start index descending. type entry struct { image markdownImage @@ -349,6 +349,7 @@ func buildImageInsertRequests(placeholders map[string]docRange, images []markdow Range: &docs.Range{ StartIndex: e.dr.startIndex, EndIndex: e.dr.endIndex, + TabId: tabID, }, }, }) @@ -370,6 +371,7 @@ func buildImageInsertRequests(placeholders map[string]docRange, images []markdow Uri: e.url, Location: &docs.Location{ Index: e.dr.startIndex, + TabId: tabID, }, ObjectSize: objSize, }, diff --git a/internal/cmd/docs_import_test.go b/internal/cmd/docs_import_test.go index a522408..bc4d5ca 100644 --- a/internal/cmd/docs_import_test.go +++ b/internal/cmd/docs_import_test.go @@ -547,7 +547,7 @@ func TestFindPlaceholderIndices_SkipsNilTextRun(t *testing.T) { func TestBuildImageInsertRequests_EmptyInputs(t *testing.T) { // All empty - reqs := buildImageInsertRequests(nil, nil, nil) + reqs := buildImageInsertRequests(nil, nil, nil, "") if len(reqs) != 0 { t.Fatalf("expected 0 requests for nil inputs, got %d", len(reqs)) } @@ -557,6 +557,7 @@ func TestBuildImageInsertRequests_EmptyInputs(t *testing.T) { make(map[string]docRange), []markdownImage{}, make(map[int]string), + "", ) if len(reqs) != 0 { t.Fatalf("expected 0 requests for empty inputs, got %d", len(reqs)) @@ -572,7 +573,7 @@ func TestBuildImageInsertRequests_SingleImage(t *testing.T) { 0: "https://example.com/img.png", } - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests (delete + insert), got %d", len(reqs)) } @@ -619,7 +620,7 @@ func TestBuildImageInsertRequests_MultipleImages_ReverseOrder(t *testing.T) { 2: "https://example.com/c.png", } - reqs := buildImageInsertRequests(placeholders, images, imageURLs) + reqs := buildImageInsertRequests(placeholders, images, imageURLs, "") // 3 images * 2 requests each = 6 if len(reqs) != 6 { t.Fatalf("expected 6 requests, got %d", len(reqs)) @@ -657,7 +658,7 @@ func TestBuildImageInsertRequests_MissingPlaceholder(t *testing.T) { 0: "https://example.com/img.png", } - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 0 { t.Fatalf("expected 0 requests when placeholder missing, got %d", len(reqs)) } @@ -671,7 +672,7 @@ func TestBuildImageInsertRequests_MissingURL(t *testing.T) { } imageURLs := map[int]string{} // empty — URL not resolved - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 0 { t.Fatalf("expected 0 requests when URL missing, got %d", len(reqs)) } @@ -692,7 +693,7 @@ func TestBuildImageInsertRequests_PartialMissing(t *testing.T) { // 1 is intentionally missing } - reqs := buildImageInsertRequests(placeholders, images, imageURLs) + reqs := buildImageInsertRequests(placeholders, images, imageURLs, "") // Only 1 image produces requests (2 = delete + insert) if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) @@ -711,7 +712,7 @@ func TestBuildImageInsertRequests_DeleteRangeMatchesPlaceholder(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } @@ -729,7 +730,7 @@ func TestBuildImageInsertRequests_InsertLocationMatchesStart(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } @@ -793,7 +794,7 @@ func TestExtractAndFindPlaceholders_RoundTrip(t *testing.T) { 0: "https://example.com/a.png", 1: "https://example.com/b.jpg", } - reqs := buildImageInsertRequests(placeholders, images, imageURLs) + reqs := buildImageInsertRequests(placeholders, images, imageURLs, "") if len(reqs) != 4 { t.Fatalf("expected 4 requests, got %d", len(reqs)) } @@ -1118,7 +1119,7 @@ func TestBuildImageInsertRequests_CustomWidth(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } @@ -1138,7 +1139,7 @@ func TestBuildImageInsertRequests_CustomBothDimensions(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } @@ -1158,7 +1159,7 @@ func TestBuildImageInsertRequests_CustomHeightOnly(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } @@ -1178,7 +1179,7 @@ func TestBuildImageInsertRequests_DefaultWidth(t *testing.T) { } imageURLs := map[int]string{0: "https://x.com/a.png"} - reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs) + reqs := buildImageInsertRequests(placeholders, []markdownImage{img}, imageURLs, "") if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } diff --git a/internal/cmd/docs_mutation.go b/internal/cmd/docs_mutation.go index d491def..c579fe6 100644 --- a/internal/cmd/docs_mutation.go +++ b/internal/cmd/docs_mutation.go @@ -90,21 +90,25 @@ func runDocsReplaceAll(ctx context.Context, svc *docs.Service, docID, find, repl } func replaceDocsTextRange(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText, tabID string) error { - _, err := svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{ - WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId}, - Requests: []*docs.Request{ - { - DeleteContentRange: &docs.DeleteContentRangeRequest{ - Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID}, - }, - }, - { - InsertText: &docs.InsertTextRequest{ - Location: &docs.Location{Index: startIdx, TabId: tabID}, - Text: replaceText, - }, + requests := []*docs.Request{ + { + DeleteContentRange: &docs.DeleteContentRangeRequest{ + Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID}, }, }, + } + if replaceText != "" { + requests = append(requests, &docs.Request{ + InsertText: &docs.InsertTextRequest{ + Location: &docs.Location{Index: startIdx, TabId: tabID}, + Text: replaceText, + }, + }) + } + + _, err := svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{ + WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId}, + Requests: requests, }).Context(ctx).Do() if err != nil { return fmt.Errorf("replace: %w", err) @@ -112,26 +116,41 @@ func replaceDocsTextRange(ctx context.Context, svc *docs.Service, doc *docs.Docu return nil } -func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { +func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string, tabID string) error { cleaned, images := extractMarkdownImages(replaceText) elements := ParseMarkdown(cleaned) - formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, startIdx) + formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, startIdx, tabID) + + for _, req := range formattingRequests { + if req.UpdateTextStyle != nil && req.UpdateTextStyle.Range != nil { + req.UpdateTextStyle.Range.TabId = tabID + } + if req.UpdateParagraphStyle != nil && req.UpdateParagraphStyle.Range != nil { + req.UpdateParagraphStyle.Range.TabId = tabID + } + if req.CreateParagraphBullets != nil && req.CreateParagraphBullets.Range != nil { + req.CreateParagraphBullets.Range.TabId = tabID + } + if req.DeleteParagraphBullets != nil && req.DeleteParagraphBullets.Range != nil { + req.DeleteParagraphBullets.Range.TabId = tabID + } + } requests := make([]*docs.Request, 0, 2+len(formattingRequests)) - requests = append(requests, - &docs.Request{ - DeleteContentRange: &docs.DeleteContentRangeRequest{ - Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx}, - }, + requests = append(requests, &docs.Request{ + DeleteContentRange: &docs.DeleteContentRangeRequest{ + Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID}, }, - &docs.Request{ + }) + if textToInsert != "" { + requests = append(requests, &docs.Request{ InsertText: &docs.InsertTextRequest{ - Location: &docs.Location{Index: startIdx}, + Location: &docs.Location{Index: startIdx, TabId: tabID}, Text: textToInsert, }, - }, - ) - requests = append(requests, formattingRequests...) + }) + requests = append(requests, formattingRequests...) + } _, err := svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{ WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId}, @@ -146,7 +165,7 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. tableOffset := int64(0) for _, table := range tables { tableIndex := table.StartIndex + tableOffset - tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells) + tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID) if tableErr != nil { return fmt.Errorf("insert native table: %w", tableErr) } @@ -157,8 +176,8 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. } if len(images) > 0 { - imgErr := insertImagesIntoDocs(ctx, svc, doc.DocumentId, images) - cleanupDocsImagePlaceholders(ctx, svc, doc.DocumentId, images) + imgErr := insertImagesIntoDocs(ctx, svc, doc.DocumentId, images, tabID) + cleanupDocsImagePlaceholders(ctx, svc, doc.DocumentId, images, tabID) if imgErr != nil { return fmt.Errorf("insert images: %w", imgErr) } @@ -167,18 +186,33 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. return nil } -func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, insertIdx int64, content string) (requestCount int, inserted int, err error) { +func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, insertIdx int64, content string, tabID string) (requestCount int, inserted int, err error) { cleaned, images := extractMarkdownImages(content) elements := ParseMarkdown(cleaned) - formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, insertIdx) + formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, insertIdx, tabID) if textToInsert == "" { return 0, 0, nil } + for _, req := range formattingRequests { + if req.UpdateTextStyle != nil && req.UpdateTextStyle.Range != nil { + req.UpdateTextStyle.Range.TabId = tabID + } + if req.UpdateParagraphStyle != nil && req.UpdateParagraphStyle.Range != nil { + req.UpdateParagraphStyle.Range.TabId = tabID + } + if req.CreateParagraphBullets != nil && req.CreateParagraphBullets.Range != nil { + req.CreateParagraphBullets.Range.TabId = tabID + } + if req.DeleteParagraphBullets != nil && req.DeleteParagraphBullets.Range != nil { + req.DeleteParagraphBullets.Range.TabId = tabID + } + } + requests := make([]*docs.Request, 0, 1+len(formattingRequests)) requests = append(requests, &docs.Request{ InsertText: &docs.InsertTextRequest{ - Location: &docs.Location{Index: insertIdx}, + Location: &docs.Location{Index: insertIdx, TabId: tabID}, Text: textToInsert, }, }) @@ -196,7 +230,7 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, tableOffset := int64(0) for _, table := range tables { tableIndex := table.StartIndex + tableOffset - tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells) + tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID) if tableErr != nil { return len(requests), len(textToInsert), fmt.Errorf("insert native table: %w", tableErr) } @@ -207,8 +241,8 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, } if len(images) > 0 { - imgErr := insertImagesIntoDocs(ctx, svc, docID, images) - cleanupDocsImagePlaceholders(ctx, svc, docID, images) + imgErr := insertImagesIntoDocs(ctx, svc, docID, images, tabID) + cleanupDocsImagePlaceholders(ctx, svc, docID, images, tabID) if imgErr != nil { return len(requests), len(textToInsert), fmt.Errorf("insert images: %w", imgErr) } @@ -217,10 +251,10 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, return len(requests), len(textToInsert), nil } -func cleanupDocsImagePlaceholders(ctx context.Context, svc *docs.Service, docID string, images []markdownImage) { +func cleanupDocsImagePlaceholders(ctx context.Context, svc *docs.Service, docID string, images []markdownImage, tabID string) { reqs := make([]*docs.Request, 0, len(images)) for _, img := range images { - reqs = append(reqs, &docs.Request{ + req := &docs.Request{ ReplaceAllText: &docs.ReplaceAllTextRequest{ ContainsText: &docs.SubstringMatchCriteria{ Text: img.placeholder(), @@ -228,7 +262,11 @@ func cleanupDocsImagePlaceholders(ctx context.Context, svc *docs.Service, docID }, ReplaceText: "", }, - }) + } + if tabID != "" { + req.ReplaceAllText.TabsCriteria = &docs.TabsCriteria{TabIds: []string{tabID}} + } + reqs = append(reqs, req) } _, _ = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ Requests: reqs, diff --git a/internal/cmd/docs_tab_edit_test.go b/internal/cmd/docs_tab_edit_test.go index decdffa..a876d1a 100644 --- a/internal/cmd/docs_tab_edit_test.go +++ b/internal/cmd/docs_tab_edit_test.go @@ -101,22 +101,29 @@ func TestDocsWriteUpdate_WithTab(t *testing.T) { t.Fatalf("unexpected append insert location: %#v", got) } + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", "**markdown**", "--append", "--markdown", "--tab", "Second"}, ctx, flags); err != nil { + t.Fatalf("write markdown append: %v", err) + } + if got := batchRequests[2][0].InsertText.Location; got.TabId != "t.second" || got.Index != 19 { + t.Fatalf("unexpected markdown append insert location: %#v", got) + } + if err := runKong(t, &DocsUpdateCmd{}, []string{"doc1", "--text", "!", "--tab", "t.second"}, ctx, flags); err != nil { t.Fatalf("update append: %v", err) } - if got := batchRequests[2][0].InsertText.Location; got.TabId != "t.second" || got.Index != 19 { + if got := batchRequests[3][0].InsertText.Location; got.TabId != "t.second" || got.Index != 19 { t.Fatalf("unexpected update insert location: %#v", got) } if err := runKong(t, &DocsUpdateCmd{}, []string{"doc1", "--text", "?", "--index", "5", "--tab", "t.second"}, ctx, flags); err != nil { t.Fatalf("update explicit index: %v", err) } - if got := batchRequests[3][0].InsertText.Location; got.TabId != "t.second" || got.Index != 5 { + if got := batchRequests[4][0].InsertText.Location; got.TabId != "t.second" || got.Index != 5 { t.Fatalf("unexpected indexed update location: %#v", got) } - if includeTabsCalls != 4 { - t.Fatalf("expected 4 tab-aware GET calls, got %d", includeTabsCalls) + if includeTabsCalls != 5 { + t.Fatalf("expected 5 tab-aware GET calls, got %d", includeTabsCalls) } } @@ -199,6 +206,10 @@ func TestDocsEditingCommands_WithTab(t *testing.T) { if req == nil || req.TabsCriteria == nil || len(req.TabsCriteria.TabIds) != 1 || req.TabsCriteria.TabIds[0] != "t.second" { t.Fatalf("unexpected tabs criteria: %#v", req) } + + if err := runKong(t, &DocsFindReplaceCmd{}, []string{"doc1", "old", "**new**", "--format", "markdown", "--tab", "Second"}, ctx, flags); err != nil { + t.Fatalf("find-replace markdown tab: %v", err) + } } func TestDocsWriteCmd_DeprecatedTabIDFlag(t *testing.T) { diff --git a/internal/cmd/docs_tab_manage.go b/internal/cmd/docs_tab_manage.go new file mode 100644 index 0000000..402aab4 --- /dev/null +++ b/internal/cmd/docs_tab_manage.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "google.golang.org/api/docs/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DocsAddTabCmd struct { + DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` + Title string `name:"title" help:"User-visible tab title"` + Index *int64 `name:"index" help:"Zero-based tab index within the parent"` + ParentTab string `name:"parent-tab" help:"Optional parent tab title or ID"` + IconEmoji string `name:"icon-emoji" help:"Optional tab emoji icon"` +} + +func (c *DocsAddTabCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := normalizeGoogleID(strings.TrimSpace(c.DocID)) + if docID == "" { + return usage("empty docId") + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + props := &docs.TabProperties{} + if title := strings.TrimSpace(c.Title); title != "" { + props.Title = title + } + if c.Index != nil { + props.Index = *c.Index + props.ForceSendFields = append(props.ForceSendFields, "Index") + } + if emoji := strings.TrimSpace(c.IconEmoji); emoji != "" { + props.IconEmoji = emoji + } + if parent := strings.TrimSpace(c.ParentTab); parent != "" { + parentID, parentErr := docsResolveTabID(ctx, svc, docID, parent) + if parentErr != nil { + return parentErr + } + props.ParentTabId = parentID + } + + if dryRunErr := dryRunExit(ctx, flags, "docs.add-tab", map[string]any{ + "doc_id": docID, + "title": props.Title, + "index": c.Index, + "parent_tab": props.ParentTabId, + "icon_emoji": props.IconEmoji, + }); dryRunErr != nil { + return dryRunErr + } + + resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ + AddDocumentTab: &docs.AddDocumentTabRequest{TabProperties: props}, + }}, + }).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + if resp == nil { + return errors.New("add tab failed") + } + + var created *docs.TabProperties + if len(resp.Replies) > 0 && resp.Replies[0] != nil && resp.Replies[0].AddDocumentTab != nil { + created = resp.Replies[0].AddDocumentTab.TabProperties + } + if outfmt.IsJSON(ctx) { + payload := map[string]any{"documentId": docID} + if created != nil { + payload["tab"] = tabPropertiesJSON(created) + } + if resp.WriteControl != nil { + payload["writeControl"] = resp.WriteControl + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + u.Out().Printf("docId\t%s", docID) + if created != nil { + u.Out().Printf("tabId\t%s", created.TabId) + u.Out().Printf("title\t%s", created.Title) + u.Out().Printf("index\t%d", created.Index) + if created.ParentTabId != "" { + u.Out().Printf("parentTabId\t%s", created.ParentTabId) + } + } + if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { + u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId) + } + return nil +} + +type DocsRenameTabCmd struct { + DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` + Tab string `name:"tab" help:"Existing tab title or ID"` + Title string `name:"title" help:"New user-visible tab title"` +} + +func (c *DocsRenameTabCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := normalizeGoogleID(strings.TrimSpace(c.DocID)) + tabQuery := strings.TrimSpace(c.Tab) + newTitle := strings.TrimSpace(c.Title) + if docID == "" { + return usage("empty docId") + } + if tabQuery == "" { + return usage("empty --tab") + } + if newTitle == "" { + return usage("empty --title") + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + resolved, err := docsResolveTab(ctx, svc, docID, tabQuery) + if err != nil { + return err + } + + if dryRunErr := dryRunExit(ctx, flags, "docs.rename-tab", map[string]any{ + "doc_id": docID, + "tab_id": resolved.TabProperties.TabId, + "title": newTitle, + }); dryRunErr != nil { + return dryRunErr + } + + resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ + UpdateDocumentTabProperties: &docs.UpdateDocumentTabPropertiesRequest{ + Fields: "title", + TabProperties: &docs.TabProperties{ + TabId: resolved.TabProperties.TabId, + Title: newTitle, + }, + }, + }}, + }).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "tab": map[string]any{"id": resolved.TabProperties.TabId, "title": newTitle}, + } + if resp != nil && resp.WriteControl != nil { + payload["writeControl"] = resp.WriteControl + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + u.Out().Printf("docId\t%s", docID) + u.Out().Printf("tabId\t%s", resolved.TabProperties.TabId) + u.Out().Printf("title\t%s", newTitle) + if resp != nil && resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { + u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId) + } + return nil +} + +type DocsDeleteTabCmd struct { + DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` + Tab string `name:"tab" help:"Existing tab title or ID"` +} + +func (c *DocsDeleteTabCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := normalizeGoogleID(strings.TrimSpace(c.DocID)) + tabQuery := strings.TrimSpace(c.Tab) + if docID == "" { + return usage("empty docId") + } + if tabQuery == "" { + return usage("empty --tab") + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + resolved, err := docsResolveTab(ctx, svc, docID, tabQuery) + if err != nil { + return err + } + + if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("delete tab %s from doc %s", resolved.TabProperties.TabId, docID)); confirmErr != nil { + return confirmErr + } + if dryRunErr := dryRunExit(ctx, flags, "docs.delete-tab", map[string]any{ + "doc_id": docID, + "tab_id": resolved.TabProperties.TabId, + }); dryRunErr != nil { + return dryRunErr + } + + resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ + DeleteTab: &docs.DeleteTabRequest{TabId: resolved.TabProperties.TabId}, + }}, + }).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "deleted": true, + "tab": map[string]any{"id": resolved.TabProperties.TabId, "title": resolved.TabProperties.Title}, + } + if resp != nil && resp.WriteControl != nil { + payload["writeControl"] = resp.WriteControl + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + u.Out().Printf("docId\t%s", docID) + u.Out().Printf("tabId\t%s", resolved.TabProperties.TabId) + u.Out().Printf("deleted\ttrue") + if resolved.TabProperties.Title != "" { + u.Out().Printf("title\t%s", resolved.TabProperties.Title) + } + if resp != nil && resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { + u.Out().Printf("revision\t%s", resp.WriteControl.RequiredRevisionId) + } + return nil +} + +func docsResolveTab(ctx context.Context, svc *docs.Service, docID, query string) (*docs.Tab, error) { + doc, err := svc.Documents.Get(docID).IncludeTabsContent(true).Context(ctx).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 + } + if doc == nil { + return nil, errors.New("doc not found") + } + return findTab(flattenTabs(doc.Tabs), query) +} + +func docsResolveTabID(ctx context.Context, svc *docs.Service, docID, query string) (string, error) { + tab, err := docsResolveTab(ctx, svc, docID, query) + if err != nil { + return "", err + } + if tab == nil || tab.TabProperties == nil || strings.TrimSpace(tab.TabProperties.TabId) == "" { + return "", fmt.Errorf("tab not found: %q", query) + } + return tab.TabProperties.TabId, nil +} + +func tabPropertiesJSON(props *docs.TabProperties) map[string]any { + if props == nil { + return nil + } + payload := map[string]any{ + "id": props.TabId, + "title": props.Title, + "index": props.Index, + } + if props.ParentTabId != "" { + payload["parentTabId"] = props.ParentTabId + } + if props.IconEmoji != "" { + payload["iconEmoji"] = props.IconEmoji + } + if props.NestingLevel != 0 { + payload["nestingLevel"] = props.NestingLevel + } + return payload +} diff --git a/internal/cmd/docs_tab_manage_test.go b/internal/cmd/docs_tab_manage_test.go new file mode 100644 index 0000000..995a654 --- /dev/null +++ b/internal/cmd/docs_tab_manage_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" +) + +func TestDocsAddRenameDeleteTab(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var includeTabsCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + if strings.Contains(r.URL.RawQuery, "includeTabsContent=true") { + includeTabsCalls++ + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tabsDocWithEndIndex()) + return + case r.Method == http.MethodPost && strings.Contains(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", + "replies": []any{ + map[string]any{ + "addDocumentTab": map[string]any{ + "tabProperties": map[string]any{"tabId": "t.third", "title": "Third", "index": 2}, + }, + }, + }, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com", Force: true} + ctx := newDocsCmdContext(t) + + idx := int64(2) + if err := runKong(t, &DocsAddTabCmd{}, []string{"doc1", "--title", "Third", "--index", "2"}, ctx, flags); err != nil { + t.Fatalf("add-tab: %v", err) + } + addReq := batchRequests[0][0].AddDocumentTab + if addReq == nil || addReq.TabProperties == nil { + t.Fatalf("unexpected add request: %#v", batchRequests[0][0]) + } + if addReq.TabProperties.Title != "Third" || addReq.TabProperties.Index != idx { + t.Fatalf("unexpected add props: %#v", addReq.TabProperties) + } + + if err := runKong(t, &DocsRenameTabCmd{}, []string{"doc1", "--tab", "Second", "--title", "TWO"}, ctx, flags); err != nil { + t.Fatalf("rename-tab: %v", err) + } + renameReq := batchRequests[1][0].UpdateDocumentTabProperties + if renameReq == nil || renameReq.TabProperties == nil { + t.Fatalf("unexpected rename request: %#v", batchRequests[1][0]) + } + if renameReq.Fields != "title" || renameReq.TabProperties.TabId != "t.second" || renameReq.TabProperties.Title != "TWO" { + t.Fatalf("unexpected rename props: %#v", renameReq) + } + + if err := runKong(t, &DocsDeleteTabCmd{}, []string{"doc1", "--tab", "Second"}, ctx, flags); err != nil { + t.Fatalf("delete-tab: %v", err) + } + deleteReq := batchRequests[2][0].DeleteTab + if deleteReq == nil || deleteReq.TabId != "t.second" { + t.Fatalf("unexpected delete request: %#v", batchRequests[2][0]) + } + + if includeTabsCalls != 2 { + t.Fatalf("expected 2 tab-aware GET calls, got %d", includeTabsCalls) + } +} + +func TestDocsRenameDeleteTab_NotFound(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tabsDocWithEndIndex()) + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com", Force: true} + ctx := newDocsCmdContext(t) + + err := runKong(t, &DocsRenameTabCmd{}, []string{"doc1", "--tab", "Missing", "--title", "X"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), `tab not found: "Missing"`) { + t.Fatalf("unexpected rename error: %v", err) + } + + err = runKong(t, &DocsDeleteTabCmd{}, []string{"doc1", "--tab", "Missing"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), `tab not found: "Missing"`) { + t.Fatalf("unexpected delete error: %v", err) + } +} diff --git a/internal/cmd/docs_table_inserter.go b/internal/cmd/docs_table_inserter.go index a25a8dd..57c6d35 100644 --- a/internal/cmd/docs_table_inserter.go +++ b/internal/cmd/docs_table_inserter.go @@ -22,7 +22,7 @@ func NewTableInserter(svc *docs.Service, docID string) *TableInserter { // InsertNativeTable inserts a native Google Docs table and populates it with content // Returns the end index of the table after insertion -func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64, cells [][]string) (int64, error) { +func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64, cells [][]string, tabID string) (int64, error) { if len(cells) == 0 || len(cells[0]) == 0 { return tableIndex, nil } @@ -37,6 +37,7 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 Columns: cols, Location: &docs.Location{ Index: tableIndex, + TabId: tabID, }, }, }