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:
parent
6af52a406b
commit
6867fe850c
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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, ","),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
301
internal/cmd/docs_tab_manage.go
Normal file
301
internal/cmd/docs_tab_manage.go
Normal 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
|
||||
}
|
||||
118
internal/cmd/docs_tab_manage_test.go
Normal file
118
internal/cmd/docs_tab_manage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user