Co-authored-by: Don Bowman <5131923+donbowman@users.noreply.github.com> Co-authored-by: JoseLuis Vilar <13889217+chopenhauer@users.noreply.github.com>
354 lines
10 KiB
Go
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)
|
|
}
|
|
}
|