gogcli/internal/cmd/docs_mutation.go
Peter Steinberger 6867fe850c
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>
2026-05-04 05:22:49 +01:00

354 lines
10 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"strings"
"google.golang.org/api/docs/v1"
)
const (
docsContentFormatPlain = "plain"
docsContentFormatMarkdown = "markdown"
)
type docsLoadedTarget struct {
full *docs.Document
target *docs.Document
tabID string
}
func loadDocsTargetDocument(ctx context.Context, svc *docs.Service, docID, tabID string) (*docsLoadedTarget, error) {
getCall := svc.Documents.Get(docID).Context(ctx)
if tabID != "" {
getCall = getCall.IncludeTabsContent(true)
}
doc, err := getCall.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")
}
if tabID == "" {
return &docsLoadedTarget{full: doc, target: doc}, nil
}
tab, tabErr := findTab(flattenTabs(doc.Tabs), tabID)
if tabErr != nil {
return nil, tabErr
}
resolvedTabID := ""
if tab.TabProperties != nil {
resolvedTabID = strings.TrimSpace(tab.TabProperties.TabId)
}
if resolvedTabID == "" {
return nil, fmt.Errorf("tab has no ID: %s", tabID)
}
if tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
return nil, fmt.Errorf("tab has no document body: %s", tabID)
}
return &docsLoadedTarget{
full: doc,
target: &docs.Document{
DocumentId: doc.DocumentId,
RevisionId: doc.RevisionId,
Body: tab.DocumentTab.Body,
},
tabID: resolvedTabID,
}, nil
}
func runDocsReplaceAll(ctx context.Context, svc *docs.Service, docID, find, replaceText string, matchCase bool, tabID string) (string, int64, error) {
req := &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{Text: find, MatchCase: matchCase},
ReplaceText: replaceText,
}
if tabID != "" {
req.TabsCriteria = &docs.TabsCriteria{TabIds: []string{tabID}}
}
result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: []*docs.Request{{ReplaceAllText: req}},
}).Context(ctx).Do()
if err != nil {
return "", 0, fmt.Errorf("find-replace: %w", err)
}
var replacements int64
if len(result.Replies) > 0 && result.Replies[0].ReplaceAllText != nil {
replacements = result.Replies[0].ReplaceAllText.OccurrencesChanged
}
return result.DocumentId, replacements, nil
}
func replaceDocsTextRange(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText, tabID string) error {
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)
}
return nil
}
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, 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, TabId: tabID},
},
})
if textToInsert != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: startIdx, TabId: tabID},
Text: textToInsert,
},
})
requests = append(requests, formattingRequests...)
}
_, 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 (markdown): %w", err)
}
if len(tables) > 0 {
tableInserter := NewTableInserter(svc, doc.DocumentId)
tableOffset := int64(0)
for _, table := range tables {
tableIndex := table.StartIndex + tableOffset
tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID)
if tableErr != nil {
return fmt.Errorf("insert native table: %w", tableErr)
}
if tableEnd > tableIndex {
tableOffset += (tableEnd - tableIndex) - 1
}
}
}
if len(images) > 0 {
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)
}
}
return nil
}
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, 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, TabId: tabID},
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, tabID)
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, svc, docID, images, tabID)
cleanupDocsImagePlaceholders(ctx, svc, docID, images, tabID)
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, tabID string) {
reqs := make([]*docs.Request, 0, len(images))
for _, img := range images {
req := &docs.Request{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: img.placeholder(),
MatchCase: true,
},
ReplaceText: "",
},
}
if tabID != "" {
req.ReplaceAllText.TabsCriteria = &docs.TabsCriteria{TabIds: []string{tabID}}
}
reqs = append(reqs, req)
}
_, _ = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: reqs,
}).Context(ctx).Do()
}
func findTextInDoc(doc *docs.Document, searchText string, matchCase bool) (int64, int64, int) {
matches := findTextMatches(doc, searchText, matchCase)
if len(matches) == 0 {
return 0, 0, 0
}
return matches[0].startIndex, matches[0].endIndex, len(matches)
}
func findTextMatches(doc *docs.Document, searchText string, matchCase bool) []docRange {
if doc == nil || doc.Body == nil {
return nil
}
find := searchText
if !matchCase {
find = strings.ToLower(find)
}
var matches []docRange
findTextInElements(doc.Body.Content, searchText, find, matchCase, &matches)
return matches
}
func findTextInElements(elements []*docs.StructuralElement, searchText, find string, matchCase bool, matches *[]docRange) {
for _, el := range elements {
if el == nil {
continue
}
switch {
case el.Paragraph != nil:
findTextInParagraph(el.Paragraph, searchText, find, matchCase, matches)
case el.Table != nil:
for _, row := range el.Table.TableRows {
for _, cell := range row.TableCells {
findTextInElements(cell.Content, searchText, find, matchCase, matches)
}
}
}
}
}
func findTextInParagraph(para *docs.Paragraph, searchText, find string, matchCase bool, matches *[]docRange) {
var paraText strings.Builder
var paraStart int64
first := true
for _, pe := range para.Elements {
if pe.TextRun == nil {
continue
}
if first {
paraStart = pe.StartIndex
first = false
}
paraText.WriteString(pe.TextRun.Content)
}
if paraText.Len() == 0 {
return
}
text := paraText.String()
compareText := text
if !matchCase {
compareText = strings.ToLower(text)
}
offset := 0
for {
idx := strings.Index(compareText[offset:], find)
if idx < 0 {
break
}
absIdx := offset + idx
matchStart := paraStart + utf16Len(text[:absIdx])
matchEnd := matchStart + utf16Len(searchText)
*matches = append(*matches, docRange{startIndex: matchStart, endIndex: matchEnd})
offset = absIdx + len(find)
}
}