Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ gog docs write <docId> --text "Fresh content"
gog docs write <docId> --text "Rewrite one tab" --tab-id t.notes
gog docs write <docId> --file ./body.txt --append --pageless
gog docs write <docId> --file ./body.md --replace --markdown
gog docs write <docId> --file ./body.md --append --markdown
gog docs find-replace <docId> "old" "new"
gog docs find-replace <docId> "old" "new" --tab-id t.notes

Expand Down
75 changes: 68 additions & 7 deletions internal/cmd/docs_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"

"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
Expand All @@ -19,8 +20,8 @@ type DocsWriteCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to write"`
File string `name:"file" help:"Text file path ('-' for stdin)"`
Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown)"`
Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace)"`
Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown unless --append is set)"`
Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"`
Append bool `name:"append" help:"Append instead of replacing the document body"`
Pageless bool `name:"pageless" help:"Set document to pageless mode"`
TabID string `name:"tab-id" help:"Target a specific tab by ID (see docs list-tabs)"`
Expand Down Expand Up @@ -155,15 +156,15 @@ func (c *DocsWriteCmd) writePlainTextResult(ctx context.Context, resp *docs.Batc
func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error {
u := ui.FromContext(ctx)

if !c.Replace {
return usage("--markdown requires --replace")
}
if c.Append {
return usage("--markdown cannot be combined with --append")
if !c.Replace && !c.Append {
return usage("--markdown requires --replace or --append")
}
if c.TabID != "" {
return usage("--markdown cannot be combined with --tab-id")
}
if c.Append {
return c.appendMarkdown(ctx, flags, docID, content)
}

_, driveSvc, err := requireDriveService(ctx, flags)
if err != nil {
Expand Down Expand Up @@ -215,6 +216,66 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI
return nil
}

func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}

endIndex, err := docsTargetEndIndex(ctx, svc, docID, "")
if err != nil {
return err
}
insertIndex := docsAppendIndex(endIndex)

basePath := c.File
if basePath == "" || basePath == "-" {
basePath = "."
} else if expanded, expandErr := config.ExpandPath(basePath); expandErr == nil {
basePath = expanded
}
requestCount, inserted, err := insertDocsMarkdownAt(ctx, svc, account, docID, insertIndex, content, basePath)
if err != nil {
if isDocsNotFound(err) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
}
return err
}
if err := c.applyPageless(ctx, svc, docID); err != nil {
return err
}

if outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": docID,
"written": inserted,
"requests": requestCount,
"append": true,
"index": insertIndex,
"markdown": true,
}
if c.Pageless {
payload["pageless"] = true
}
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}

u := ui.FromContext(ctx)
u.Out().Printf("documentId\t%s", docID)
u.Out().Printf("written\t%d", inserted)
u.Out().Printf("requests\t%d", requestCount)
u.Out().Printf("mode\tappended (markdown converted)")
u.Out().Printf("index\t%d", insertIndex)
if c.Pageless {
u.Out().Printf("pageless\ttrue")
}
return nil
}

type DocsUpdateCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to insert"`
Expand Down
50 changes: 50 additions & 0 deletions internal/cmd/docs_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,56 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, account st
return nil
}

func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, account, docID string, insertIdx int64, content, basePath string) (requestCount int, inserted int, err error) {
cleaned, images := extractMarkdownImages(content)
elements := ParseMarkdown(cleaned)
formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, insertIdx)
if textToInsert == "" {
return 0, 0, nil
}

requests := make([]*docs.Request, 0, 1+len(formattingRequests))
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: textToInsert,
},
})
requests = append(requests, formattingRequests...)

_, err = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
if err != nil {
return 0, 0, fmt.Errorf("append (markdown): %w", err)
}

if len(tables) > 0 {
tableInserter := NewTableInserter(svc, docID)
tableOffset := int64(0)
for _, table := range tables {
tableIndex := table.StartIndex + tableOffset
tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells)
if tableErr != nil {
return len(requests), len(textToInsert), fmt.Errorf("insert native table: %w", tableErr)
}
if tableEnd > tableIndex {
tableOffset += (tableEnd - tableIndex) - 1
}
}
}

if len(images) > 0 {
imgErr := insertImagesIntoDocs(ctx, account, svc, docID, images, basePath)
cleanupDocsImagePlaceholders(ctx, svc, docID, images)
if imgErr != nil {
return len(requests), len(textToInsert), fmt.Errorf("insert images: %w", imgErr)
}
}

return len(requests), len(textToInsert), nil
}

func cleanupDocsImagePlaceholders(ctx context.Context, svc *docs.Service, docID string, images []markdownImage) {
reqs := make([]*docs.Request, 0, len(images))
for _, img := range images {
Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/docs_write_markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,83 @@ func TestDocsWrite_MarkdownReplaceUsesDriveUpdate(t *testing.T) {
t.Fatalf("expected upload body to contain markdown content, got: %q", uploadBody)
}
}

func TestDocsWrite_MarkdownAppendUsesDocsFormatting(t *testing.T) {
origDocs := newDocsService
origDrive := newDriveService
t.Cleanup(func() {
newDocsService = origDocs
newDriveService = origDrive
})

var batchRequests [][]*docs.Request

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/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"documentId": "doc1",
"body": map[string]any{
"content": []any{
map[string]any{"startIndex": 1, "endIndex": 20},
},
},
})
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 batch request: %v", err)
}
batchRequests = append(batchRequests, req.Requests)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
return
default:
http.NotFound(w, r)
return
}
}))
defer cleanup()

newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }
newDriveService = func(context.Context, string) (*drive.Service, error) {
t.Fatal("markdown append should not use Drive update")
return nil, errors.New("unexpected Drive service call")
}

flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsJSONContext(t)

markdown := "# Title\n\n**bold**\n"
if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", markdown, "--append", "--markdown"}, ctx, flags); err != nil {
t.Fatalf("markdown append write: %v", err)
}
if len(batchRequests) != 1 {
t.Fatalf("expected 1 batch request, got %d", len(batchRequests))
}
reqs := batchRequests[0]
if len(reqs) != 3 {
t.Fatalf("expected insert plus 2 formatting requests, got %#v", reqs)
}
if reqs[0].InsertText == nil {
t.Fatalf("expected first request to insert text, got %#v", reqs[0])
}
if got := reqs[0].InsertText; got.Location.Index != 19 || got.Text != "Title\nbold\n" {
t.Fatalf("unexpected markdown insert: %#v", got)
}
if reqs[1].UpdateParagraphStyle == nil {
t.Fatalf("expected heading paragraph style request, got %#v", reqs[1])
}
if got := reqs[1].UpdateParagraphStyle.Range; got.StartIndex != 19 || got.EndIndex != 25 {
t.Fatalf("unexpected heading range: %#v", got)
}
if reqs[2].UpdateTextStyle == nil {
t.Fatalf("expected bold text style request, got %#v", reqs[2])
}
if got := reqs[2].UpdateTextStyle.Range; got.StartIndex != 25 || got.EndIndex != 29 {
t.Fatalf("unexpected bold range: %#v", got)
}
}