gogcli/internal/cmd/docs_sed_commands.go
Bill 4185e02a54
fix(docs): preserve sed formatting offsets
Use UTF-16 offsets for sed formatting ranges and preserve whole-match & replacements.\n\nThanks @bill492.
2026-04-20 13:29:35 +01:00

482 lines
13 KiB
Go

package cmd
import (
"context"
"fmt"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
// fetchDoc creates a Docs service and fetches the document. Used by command implementations
// that need the full document structure (delete, append, insert).
func fetchDoc(ctx context.Context, account, id string) (*docs.Service, *docs.Document, error) {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return nil, nil, fmt.Errorf("create docs service: %w", err)
}
doc, err := getDoc(ctx, docsSvc, id)
if err != nil {
return nil, nil, fmt.Errorf("get document: %w", err)
}
return docsSvc, doc, nil
}
// runDeleteCommand executes a d/pattern/ command, deleting all lines containing the pattern.
func (c *DocsSedCmd) runDeleteCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
re, err := expr.compilePattern()
if err != nil {
return fmt.Errorf("compile pattern: %w", err)
}
// Find paragraphs matching the pattern and collect their ranges for deletion
var requests []*docs.Request
deleted := 0
// Walk in reverse so deletions don't shift indices
if doc.Body == nil {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0 (empty document)"})
}
elems := doc.Body.Content
for i := len(elems) - 1; i >= 0; i-- {
elem := elems[i]
if elem.Paragraph == nil {
continue
}
text := extractParagraphText(elem.Paragraph)
if re.MatchString(text) {
start := elem.StartIndex
end := elem.EndIndex
// Don't delete before the document body start
if start < 1 {
start = 1
}
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: start,
EndIndex: end,
SegmentId: "",
},
},
})
deleted++
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0 (no matches)"})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (delete): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: fmt.Sprintf("%d lines", deleted)})
}
// runAppendCommand executes an a/pattern/text/ command, inserting text after each matching line.
func (c *DocsSedCmd) runAppendCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
return c.runInsertAroundMatch(ctx, u, account, id, expr, false)
}
// runInsertCommand executes an i/pattern/text/ command, inserting text before each matching line.
func (c *DocsSedCmd) runInsertCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
return c.runInsertAroundMatch(ctx, u, account, id, expr, true)
}
// runInsertAroundMatch implements both append-after and insert-before matching lines.
func (c *DocsSedCmd) runInsertAroundMatch(ctx context.Context, u *ui.UI, account, id string, expr sedExpr, before bool) error {
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
resultKey := "appended"
if before {
resultKey = "inserted"
}
re, err := expr.compilePattern()
if err != nil {
return fmt.Errorf("compile pattern: %w", err)
}
// Process replacement text: convert \n to real newlines
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
if !strings.HasSuffix(insertText, "\n") {
insertText += "\n"
}
// Collect insertion points (in reverse order to preserve indices)
var insertPoints []int64
if doc.Body == nil {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: resultKey, Value: "0 (empty document)"})
}
for _, elem := range doc.Body.Content {
if elem.Paragraph == nil {
continue
}
text := extractParagraphText(elem.Paragraph)
if re.MatchString(text) {
if before {
insertPoints = append(insertPoints, elem.StartIndex)
} else {
insertPoints = append(insertPoints, elem.EndIndex)
}
}
}
if len(insertPoints) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: resultKey, Value: "0 (no matches)"})
}
// Build requests in reverse document order
var requests []*docs.Request
for i := len(insertPoints) - 1; i >= 0; i-- {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertPoints[i]},
Text: insertText,
},
})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (insert): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: resultKey, Value: fmt.Sprintf("%d lines", len(insertPoints))})
}
// runTransliterate executes a y/source/dest/ command, replacing each character in source
// with the corresponding character in dest throughout the document.
func (c *DocsSedCmd) runTransliterate(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
docsSvc, _, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
sourceRunes := []rune(expr.pattern)
destRunes := []rune(expr.replacement)
// Use native FindReplace for each character pair
var requests []*docs.Request
for i, src := range sourceRunes {
requests = append(requests, &docs.Request{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: string(src),
MatchCase: true,
},
ReplaceText: string(destRunes[i]),
},
})
}
resp, err := batchUpdate(ctx, docsSvc, id, requests)
if err != nil {
return fmt.Errorf("batch update (transliterate): %w", err)
}
var replaced int
if resp != nil {
for _, reply := range resp.Replies {
if reply.ReplaceAllText != nil {
replaced += int(reply.ReplaceAllText.OccurrencesChanged)
}
}
}
return sedOutputOK(ctx, u, id,
sedOutputKV{Key: "transliterated", Value: fmt.Sprintf("%d chars across %d pairs", replaced, len(sourceRunes))},
)
}
// extractParagraphText returns the plain text content of a paragraph.
func extractParagraphText(p *docs.Paragraph) string {
// Fast path: single text run (most common case) avoids Builder allocation.
if len(p.Elements) == 1 && p.Elements[0].TextRun != nil {
return strings.TrimRight(p.Elements[0].TextRun.Content, "\n")
}
var sb strings.Builder
for _, elem := range p.Elements {
if elem.TextRun != nil {
sb.WriteString(elem.TextRun.Content)
}
}
return strings.TrimRight(sb.String(), "\n")
}
// --- Addressed command implementations ---
// resolveAddress converts a sedAddress into a slice of target paragraphs from the map.
func resolveAddress(addr *sedAddress, pm *paragraphMap) ([]docParagraph, error) {
if addr == nil {
return nil, fmt.Errorf("nil address")
}
if len(pm.Paragraphs) == 0 {
return nil, fmt.Errorf("document has no paragraphs")
}
last := len(pm.Paragraphs)
start := addr.Start
if start == -1 {
start = last
}
if start < 1 || start > last {
return nil, fmt.Errorf("address %d out of range (document has %d paragraphs)", start, last)
}
if !addr.HasRange {
return []docParagraph{pm.Paragraphs[start-1]}, nil
}
end := addr.End
if end == 0 {
end = start
}
if end == -1 {
end = last
}
if end < 1 || end > last {
return nil, fmt.Errorf("address end %d out of range (document has %d paragraphs)", end, last)
}
return pm.Paragraphs[start-1 : end], nil
}
// runAddressedDelete deletes paragraphs by address (number or range).
func (c *DocsSedCmd) runAddressedDelete(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
// Build delete requests in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
startIndex := para.StartIndex
endIndex := para.EndIndex
isLast := para.Num == len(pm.Paragraphs)
if isLast && para.Num > 1 {
// Last paragraph: delete from end of previous paragraph to our end-1
prev := pm.Paragraphs[para.Num-2]
startIndex = prev.EndIndex - 1
endIndex = para.EndIndex - 1
} else if isLast && para.Num == 1 {
// Only paragraph: just clear the text
if para.StartIndex >= para.EndIndex-1 {
continue // empty paragraph, skip
}
endIndex = para.EndIndex - 1
}
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: startIndex,
EndIndex: endIndex,
TabId: pm.TabID,
},
},
})
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0"})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed delete): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedAppend inserts text after the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedAppend(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
if !strings.HasSuffix(insertText, "\n") {
insertText = "\n" + insertText
} else {
insertText = "\n" + insertText[:len(insertText)-1]
}
// Insert in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
// Insert before the trailing \n of the paragraph
idx := para.EndIndex - 1
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: idx, TabId: pm.TabID},
Text: insertText,
},
})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed append): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "appended", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedInsert inserts text before the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedInsert(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
if !strings.HasSuffix(insertText, "\n") {
insertText += "\n"
}
// Insert in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: para.StartIndex, TabId: pm.TabID},
Text: insertText,
},
})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed insert): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "inserted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedSubstitute applies a substitution only within the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedSubstitute(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
re, compileErr := expr.compilePattern()
if compileErr != nil {
return fmt.Errorf("compile pattern: %w", compileErr)
}
// For each target paragraph, find matches and apply substitutions.
// Work in reverse order to preserve indices.
var requests []*docs.Request
replaced := 0
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
text := para.Text
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
continue
}
if !expr.global {
matches = matches[:1]
}
// Process matches in reverse order within this paragraph
for j := len(matches) - 1; j >= 0; j-- {
m := matches[j]
matchText := text[m[0]:m[1]]
replText := re.ReplaceAllString(matchText, expr.replacement)
// Unescape Go regex $$ to literal $ after regex expansion.
// Keep expanded whole-match/capture substitutions intact.
replText = strings.ReplaceAll(replText, "$$", "$")
absStart := para.StartIndex + int64(m[0])
absEnd := para.StartIndex + int64(m[1])
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: absStart,
EndIndex: absEnd,
TabId: pm.TabID,
},
},
})
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: absStart, TabId: pm.TabID},
Text: replText,
},
})
replaced++
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: 0})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed substitute): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: replaced})
}