feat(docs): add tab-aware editing fixes

Co-authored-by: Don Bowman <5131923+donbowman@users.noreply.github.com>
Co-authored-by: JoseLuis Vilar <13889217+chopenhauer@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-04 05:22:49 +01:00
parent 6af52a406b
commit 6867fe850c
No known key found for this signature in database
13 changed files with 730 additions and 89 deletions

View File

@ -71,7 +71,7 @@ Generated from `gog schema --json`.
- `gog calendar (cal) create-calendar (new-calendar) <summary> [flags]` - Create a new secondary calendar
- `gog calendar (cal) delete (rm,del,remove) <calendarId> <eventId> [flags]` - Delete an event
- `gog calendar (cal) event (get,info,show) <calendarId> <eventId>` - Get event
- `gog calendar (cal) events (list,ls) [<calendarId>] [flags]` - List events from a calendar or all calendars
- `gog calendar (cal) events (list,ls) [<calendarId> ...] [flags]` - List events from a calendar or all calendars
- `gog calendar (cal) focus-time (focus) --from=STRING --to=STRING [<calendarId>] [flags]` - Create a Focus Time block
- `gog calendar (cal) freebusy [<calendarIds>] [flags]` - Get free/busy
- `gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [<calendarId>] [flags]` - Create an Out of Office event
@ -202,6 +202,7 @@ Generated from `gog schema --json`.
- `gog contacts (contact) search <query> ... [flags]` - Search contacts by name/email/phone
- `gog contacts (contact) update (edit,set) <resourceName> [flags]` - Update a contact
- `gog docs (doc) <command> [flags]` - Google Docs (export via Drive)
- `gog docs (doc) add-tab <docId> [flags]` - Add a tab to a Google Doc
- `gog docs (doc) cat (text,read) <docId> [flags]` - Print a Google Doc as plain text
- `gog docs (doc) clear <docId>` - Clear all content from a Google Doc
- `gog docs (doc) comments <command>` - Manage comments on files
@ -214,12 +215,14 @@ Generated from `gog schema --json`.
- `gog docs (doc) copy (cp,duplicate) <docId> <title> [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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 })

View File

@ -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, ","),

View File

@ -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)

View File

@ -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,
},

View File

@ -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))
}

View File

@ -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,

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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,
},
},
}