feat(docs): add sedmat — sed-like document formatting DSL

This commit is contained in:
Vinston 2026-02-23 09:34:32 -05:00
parent 4ac03838c9
commit e2d41ad87c
28 changed files with 7233 additions and 19 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
GOG_TEST_ACCOUNT=your-google-account@example.com
GOG_TEST_DOC_ID=your-test-document-id

6
go.mod
View File

@ -6,10 +6,12 @@ require (
github.com/99designs/keyring v1.2.2
github.com/alecthomas/kong v1.13.0
github.com/muesli/termenv v0.16.0
github.com/stretchr/testify v1.11.1
github.com/yosuke-furukawa/json5 v0.1.1
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/term v0.39.0
golang.org/x/text v0.33.0
google.golang.org/api v0.260.0
)
@ -21,6 +23,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dvsekhvalnov/jose2go v1.8.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@ -34,6 +37,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
@ -42,8 +46,8 @@ require (
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

1
go.sum
View File

@ -123,6 +123,7 @@ google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@ -177,13 +177,6 @@ func primaryOrganization(p *people.Person) (name, title string) {
return p.Organizations[0].Name, p.Organizations[0].Title
}
func primaryURL(p *people.Person) string {
if p == nil || len(p.Urls) == 0 || p.Urls[0] == nil {
return ""
}
return p.Urls[0].Value
}
func allURLs(p *people.Person) []string {
if p == nil || len(p.Urls) == 0 {
return nil

View File

@ -166,11 +166,12 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u.Out().Printf("birthday\t%s", bd)
}
if org, title := primaryOrganization(p); org != "" || title != "" {
if org != "" && title != "" {
switch {
case org != "" && title != "":
u.Out().Printf("organization\t%s (%s)", org, title)
} else if org != "" {
case org != "":
u.Out().Printf("organization\t%s", org)
} else {
default:
u.Out().Printf("title\t%s", title)
}
}
@ -247,7 +248,7 @@ func contactsURLs(values []string) []*people.Url {
return out
}
func contactsApplyPersonName(person *people.Person, givenSet bool, given, familySet bool, family string) {
func contactsApplyPersonName(person *people.Person, givenSet bool, given string, familySet bool, family string) {
curGiven := ""
curFamily := ""
if len(person.Names) > 0 && person.Names[0] != nil {
@ -263,7 +264,7 @@ func contactsApplyPersonName(person *people.Person, givenSet bool, given, family
person.Names = []*people.Name{{GivenName: curGiven, FamilyName: curFamily}}
}
func contactsApplyPersonOrganization(person *people.Person, orgSet bool, org, titleSet bool, title string) {
func contactsApplyPersonOrganization(person *people.Person, orgSet bool, org string, titleSet bool, title string) {
curOrg := ""
curTitle := ""
if len(person.Organizations) > 0 && person.Organizations[0] != nil {
@ -448,11 +449,11 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
updateFields = append(updateFields, "biographies")
}
if wantCustom {
userDefined, clear, parseErr := parseCustomUserDefined(c.Custom, true)
userDefined, clearAll, parseErr := parseCustomUserDefined(c.Custom, true)
if parseErr != nil {
return usage(parseErr.Error())
}
if clear {
if clearAll {
existing.UserDefined = nil
} else {
existing.UserDefined = userDefined

View File

@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -34,6 +35,9 @@ type DocsCmd struct {
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"`
Update DocsUpdateCmd `cmd:"" name:"update" help:"Update content in a Google Doc"`
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
}
type DocsExportCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
@ -271,6 +275,7 @@ type DocsCatCmd struct {
MaxBytes int64 `name:"max-bytes" help:"Max bytes to read (0 = unlimited)" default:"2000000"`
Tab string `name:"tab" help:"Tab title or ID to read (omit for default behavior)"`
AllTabs bool `name:"all-tabs" help:"Show all tabs with headers"`
Raw bool `name:"raw" help:"Output the raw Google Docs API JSON response without modifications"`
}
func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -289,6 +294,33 @@ func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
// --raw: dump the full Google Docs API response as JSON.
if c.Raw {
call := svc.Documents.Get(id).Context(ctx)
if c.Tab != "" || c.AllTabs {
call = call.IncludeTabsContent(true)
}
doc, rawErr := call.Do()
if rawErr != nil {
if isDocsNotFound(rawErr) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
}
return rawErr
}
raw, rawErr := doc.MarshalJSON()
if rawErr != nil {
return fmt.Errorf("marshalling raw response: %w", rawErr)
}
var buf bytes.Buffer
if indentErr := json.Indent(&buf, raw, "", " "); indentErr != nil {
_, werr := os.Stdout.Write(raw)
return werr
}
buf.WriteByte('\n')
_, rawErr = buf.WriteTo(os.Stdout)
return rawErr
}
// Use tabs API when --tab or --all-tabs is specified.
if c.Tab != "" || c.AllTabs {
return c.runWithTabs(ctx, svc, id)
@ -886,6 +918,25 @@ func (c *DocsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}
type DocsClearCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
}
func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
// Clear delegates to: gog docs sed <docId> 's/^$//'
// s/^$// with empty replacement on a non-empty doc = clear all content.
docID := strings.TrimSpace(c.DocID)
if docID == "" {
return usage("empty docId")
}
sedCmd := DocsSedCmd{
DocID: docID,
Expression: `s/^$//`,
}
return sedCmd.Run(ctx, flags)
}
type DocsFindReplaceCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Find string `arg:"" name:"find" help:"Text to find"`

84
internal/cmd/docs_edit.go Normal file
View File

@ -0,0 +1,84 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
// DocsEditCmd does find/replace in a Google Doc
type DocsEditCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Find string `name:"find" short:"f" help:"Text to find" required:""`
ReplaceStr string `name:"replace" short:"r" help:"Text to replace with" required:""`
MatchCase bool `name:"match-case" help:"Case-sensitive matching" default:"true"`
}
func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}
if c.Find == "" {
return usage("empty find text")
}
// Create Docs service
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
// Build replace request
requests := []*docs.Request{
{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: c.Find,
MatchCase: c.MatchCase,
},
ReplaceText: c.ReplaceStr,
},
},
}
// Execute batch update
resp, err := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("update document: %w", err)
}
// Get count of replacements
replaced := int64(0)
if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil {
replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"status": "ok",
"docId": id,
"replaced": replaced,
})
}
u.Out().Printf("status\tok")
u.Out().Printf("docId\t%s", id)
u.Out().Printf("replaced\t%d", replaced)
return nil
}

View File

@ -7,6 +7,11 @@ import (
"unicode/utf16"
)
const (
fmtBold = "bold"
fmtBoldItalic = "bolditalic"
)
// MarkdownElementType represents the type of markdown element
type MarkdownElementType int
@ -348,7 +353,7 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
Start: idx[0],
End: idx[1],
Content: text[idx[2]:idx[3]],
Type: "bold",
Type: fmtBold,
})
}
}
@ -356,7 +361,7 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
// For italic, we need to be careful not to match asterisks that are part of bold
boldPositions := make(map[int]bool)
for _, m := range matches {
if m.Type == "bold" || m.Type == "bolditalic" {
if m.Type == fmtBold || m.Type == fmtBoldItalic {
for i := m.Start; i <= m.End; i++ {
boldPositions[i] = true
}
@ -440,8 +445,8 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
styles = append(styles, TextStyle{
Start: positionMap[m.Start],
End: positionMap[m.End],
Bold: m.Type == "bold" || m.Type == "bolditalic",
Italic: m.Type == "italic" || m.Type == "bolditalic",
Bold: m.Type == fmtBold || m.Type == fmtBoldItalic,
Italic: m.Type == "italic" || m.Type == fmtBoldItalic,
Code: m.Type == inlineTypeCode,
Link: m.URL,
})

638
internal/cmd/docs_sed.go Normal file
View File

@ -0,0 +1,638 @@
package cmd
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
// DocsSedCmd implements sed-like find-and-replace operations on Google Docs.
// It supports text replacement, regex, table operations, image insertion, and formatting.
type DocsSedCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Expression string `arg:"" optional:"" name:"expression" help:"sed expression: s/pattern/replacement/flags"`
Expressions []string `short:"e" help:"Additional sed expressions (repeatable)"`
File string `short:"f" help:"Read sed expressions from file (one per line, # comments)"`
}
// parseExpressionLines splits data into trimmed non-empty, non-comment lines.
func parseExpressionLines(data []byte) []string {
var exprs []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
exprs = append(exprs, line)
}
return exprs
}
// collectExpressions gathers sed expressions from positional arg, -e flags, -f file, and stdin.
func (c *DocsSedCmd) collectExpressions() ([]string, error) {
var exprs []string
// 1. Positional argument
if c.Expression != "" {
exprs = append(exprs, c.Expression)
}
// 2. -e flags
exprs = append(exprs, c.Expressions...)
// 3. -f file
if c.File != "" {
data, err := os.ReadFile(c.File)
if err != nil {
return nil, fmt.Errorf("read sed file: %w", err)
}
exprs = append(exprs, parseExpressionLines(data)...)
}
// 4. Stdin (only if no expressions from other sources and stdin is not a terminal)
if len(exprs) == 0 {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read stdin: %w", err)
}
exprs = append(exprs, parseExpressionLines(data)...)
}
}
if len(exprs) == 0 {
return nil, usage("no sed expressions provided (use positional arg, -e, -f, or stdin)")
}
return exprs, nil
}
type sedExpr struct {
pattern string
replacement string // escaped for Go's regexp.ReplaceAllString ($$ = literal $, ${N} = backref)
global bool
nthMatch int // >0 means replace only the Nth occurrence (e.g., s/foo/bar/2)
cellRef *tableCellRef // non-nil if targeting a specific table cell
tableRef int // non-zero if targeting a whole table (1-indexed, negative from end; math.MinInt32 = all)
command byte // 0 for s//, 'd' for delete, 'a' for append, 'i' for insert, 'y' for transliterate
brace *braceExpr // optional brace expression for SEDMAT v3.5 syntax
braceSpans []*braceSpan // positioned brace spans for inline scoping
}
type indexedExpr struct {
index int
expr sedExpr
}
// literalReplacement returns the replacement string with Go regex escaping undone,
// suitable for direct text insertion (not through regexp.ReplaceAllString).
func literalReplacement(repl string) string {
// $$ → $ (Go regex literal dollar)
result := strings.ReplaceAll(repl, "$$", "$")
// Remove ${0} (whole match backref has no meaning in direct insertion without context)
// Table wildcard code handles & expansion separately before calling this.
return result
}
func (c *DocsSedCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}
// Collect all expressions
rawExprs, err := c.collectExpressions()
if err != nil {
return fmt.Errorf("collect expressions: %w", err)
}
// Parse all expressions
var parsed []sedExpr
for i, raw := range rawExprs {
expr, parseErr := parseFullExpr(raw)
if parseErr != nil {
return fmt.Errorf("expression %d (%q): %w", i+1, raw, parseErr)
}
parsed = append(parsed, expr)
}
if flags != nil && flags.DryRun {
return c.runDryRun(ctx, u, parsed)
}
account, err := requireAccount(flags)
if err != nil {
return fmt.Errorf("require account: %w", err)
}
// Single expression: use optimized paths (native, image ref, etc.)
if len(parsed) == 1 {
return c.runSingle(ctx, u, account, id, parsed[0])
}
// Multiple expressions: batch them
return c.runBatch(ctx, u, account, id, parsed)
}
// runPositionalInsert handles ^, $, and ^$ patterns for prepend, append, and empty-doc insert.
func (c *DocsSedCmd) runPositionalInsert(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) (bool, error) {
if expr.pattern != "^$" && expr.pattern != "^" && expr.pattern != "$" {
return false, nil
}
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return true, fmt.Errorf("create docs service: %w", err)
}
var doc *docs.Document
err = retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
return e
})
if err != nil {
return true, fmt.Errorf("get document: %w", err)
}
// Find document body end index
var bodyEnd int64
if doc.Body != nil && len(doc.Body.Content) > 0 {
last := doc.Body.Content[len(doc.Body.Content)-1]
bodyEnd = last.EndIndex
}
if bodyEnd < 2 {
bodyEnd = 2 // minimum: index 1 is start, trailing \n at end
}
// Determine if document is empty (only whitespace/newlines)
isEmpty := true
if doc.Body != nil {
for _, elem := range doc.Body.Content {
if elem.Paragraph != nil {
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun != nil && strings.TrimSpace(pe.TextRun.Content) != "" {
isEmpty = false
break
}
}
}
if elem.Table != nil {
isEmpty = false
}
if !isEmpty {
break
}
}
}
switch expr.pattern {
case "^$":
if expr.replacement == "" && !isEmpty {
// s/^$// on a non-empty doc = clear all content
deleteEnd := bodyEnd - 1
if deleteEnd < 2 {
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", 0})
}
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: []*docs.Request{{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: 1,
EndIndex: deleteEnd,
},
},
}},
}).Context(ctx).Do()
return e
})
if err != nil {
return true, fmt.Errorf("clearing document: %w", err)
}
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", deleteEnd - 1})
}
if !isEmpty {
// Not empty — no match, report 0 replaced
return true, sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "document is not empty"})
}
// Empty doc with empty replacement = no-op (already empty)
if expr.replacement == "" {
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", 0})
}
// Empty doc — insert at index 1
return true, c.doPositionalInsert(ctx, docsSvc, u, id, 1, literalReplacement(expr.replacement))
case "^":
// Prepend — insert at index 1 (beginning of body)
return true, c.doPositionalInsert(ctx, docsSvc, u, id, 1, literalReplacement(expr.replacement))
case "$":
// Append — insert before the final newline
insertIdx := bodyEnd - 1
if insertIdx < 1 {
insertIdx = 1
}
return true, c.doPositionalInsert(ctx, docsSvc, u, id, insertIdx, literalReplacement(expr.replacement))
}
return false, nil
}
// runSingle executes a single sed expression, routing to the appropriate handler
// based on the expression type (command, table, positional, cell, image, native, or manual).
func (c *DocsSedCmd) runSingle(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
// Handle non-substitution commands
switch expr.command {
case 'd':
return c.runDeleteCommand(ctx, u, account, id, expr)
case 'a':
return c.runAppendCommand(ctx, u, account, id, expr)
case 'i':
return c.runInsertCommand(ctx, u, account, id, expr)
case 'y':
return c.runTransliterate(ctx, u, account, id, expr)
}
// Check table-level operations (s/|1|//, s/|*|//, etc.)
if expr.tableRef != 0 {
return c.runTableOp(ctx, u, account, id, expr)
}
// Check positional patterns first: ^$ (empty), ^ (prepend), $ (append)
if handled, err := c.runPositionalInsert(ctx, u, account, id, expr); handled {
return err
}
// Check if this targets a specific table cell
if expr.cellRef != nil {
// Check for merge/split operations
repl := strings.TrimSpace(strings.ToLower(expr.replacement))
if repl == mergeOp || repl == unmergeOp || repl == splitOp {
return c.runTableMerge(ctx, u, account, id, expr)
}
return c.runTableCellReplace(ctx, u, account, id, expr)
}
// Check if replacement is a table creation spec (|RxC|, |RxC:header|, or pipe-table)
if tableSpec := parseTableCreate(expr.replacement); tableSpec != nil {
return c.runTableCreate(ctx, u, account, id, expr, tableSpec)
}
if tableSpec := parseTableFromPipes(expr.replacement); tableSpec != nil {
return c.runTableCreate(ctx, u, account, id, expr, tableSpec)
}
// Check if pattern is an image reference (!(n), ![regex], etc.)
imgRef := parseImageRefPattern(expr.pattern)
if imgRef != nil {
return c.runImageReplace(ctx, u, account, id, imgRef, expr.replacement, expr.global)
}
// Check if we can use native API (no formatting in replacement)
if canUseNativeReplace(expr.replacement) && expr.global && expr.nthMatch <= 0 {
return c.runNative(ctx, u, account, id, expr.pattern, expr.replacement)
}
return c.runManual(ctx, u, account, id, expr)
}
// runBatch executes multiple sed expressions efficiently by batching native replacements
// into a single API call and routing other types to their specialized handlers.
func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string, exprs []sedExpr) error {
// Split into native (plain text replace) and manual (needs formatting/images)
type indexedTableExpr struct {
index int
expr sedExpr
spec *tableCreateSpec
}
var nativeExprs []indexedExpr
var manualExprs []indexedExpr
var cellExprs []indexedExpr
var tableCreateExprs []indexedTableExpr
// Positional patterns (^$, ^, $) must run individually and sequentially
// because each one changes the document state.
var positionalExprs []indexedExpr
// Image replacements must run individually — Google Docs API cannot
// reliably fetch images when mixed with other batch operations.
var imageExprs []indexedExpr
for i, expr := range exprs {
ie := indexedExpr{i, expr}
switch classifyExprForBatch(expr) {
case exprCatPositional:
positionalExprs = append(positionalExprs, ie)
case exprCatImage:
imageExprs = append(imageExprs, ie)
case exprCatCommand, exprCatImagePattern:
manualExprs = append(manualExprs, ie)
case exprCatCell:
cellExprs = append(cellExprs, ie)
case exprCatTableCreate:
spec := parseTableCreate(expr.replacement)
if spec == nil {
spec = parseTableFromPipes(expr.replacement)
}
tableCreateExprs = append(tableCreateExprs, indexedTableExpr{i, expr, spec})
case exprCatNative:
nativeExprs = append(nativeExprs, ie)
case exprCatManual:
manualExprs = append(manualExprs, ie)
}
}
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
totalReplaced := 0
// Run positional expressions sequentially (each changes doc state)
for _, ie := range positionalExprs {
if _, err := c.runPositionalInsert(ctx, u, account, id, ie.expr); err != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, err)
}
}
// Batch all native expressions into one API call
if len(nativeExprs) > 0 {
var requests []*docs.Request
for _, ie := range nativeExprs {
requests = append(requests, &docs.Request{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: ie.expr.pattern,
MatchCase: true,
SearchByRegex: true,
},
ReplaceText: ie.expr.replacement,
},
})
}
resp, err := batchUpdate(ctx, docsSvc, id, requests)
if err != nil {
return fmt.Errorf("native batch update: %w", err)
}
if resp != nil {
for _, reply := range resp.Replies {
if reply.ReplaceAllText != nil {
totalReplaced += int(reply.ReplaceAllText.OccurrencesChanged)
}
}
}
}
// Process manual expressions sequentially (each may need fresh doc state)
for _, ie := range manualExprs {
// Non-substitution commands (d/a/i/y) run through runSingle
if ie.expr.command != 0 {
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, singleErr)
}
totalReplaced++
continue
}
imgRef := parseImageRefPattern(ie.expr.pattern)
if imgRef != nil {
if imgErr := c.runImageReplace(ctx, u, account, id, imgRef, ie.expr.replacement, ie.expr.global); imgErr != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, imgErr)
}
totalReplaced++
continue
}
// Manual formatting replace — need to fetch doc each time since indices shift
count, _, manualErr := c.runManualInner(ctx, docsSvc, id, ie.expr)
if manualErr != nil {
return fmt.Errorf("expression %d (pattern=%q repl=%q): %w", ie.index+1, ie.expr.pattern, ie.expr.replacement, manualErr)
}
totalReplaced += count
}
// Apply deferred nested bullets. Re-fetches doc to find paragraphs with
// leading \t, groups them with adjacent bulleted paragraphs, and re-creates
// bullets with merged ranges so Docs interprets tabs as nesting levels.
if len(manualExprs) > 0 {
if bulletErr := c.applyDeferredBullets(ctx, docsSvc, id); bulletErr != nil {
return fmt.Errorf("apply bullets: %w", bulletErr)
}
}
// Process table creation expressions sequentially (each creates a table via API)
for _, ie := range tableCreateExprs {
if createErr := c.runTableCreate(ctx, u, account, id, ie.expr, ie.spec); createErr != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, createErr)
}
totalReplaced++
}
// Process cell expressions — batch same-table whole-cell replacements
cellReplaced, err := c.processCellExprs(ctx, u, account, id, cellExprs)
if err != nil {
return err
}
totalReplaced += cellReplaced
// Process image replacements individually (each needs its own API call for reliable image fetching).
// Add a brief pause before each image to avoid rate-limit issues with Google's image fetcher.
for _, ie := range imageExprs {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
// Retry once after a longer pause (image fetch can be flaky under load)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
if retryErr := c.runSingle(ctx, u, account, id, ie.expr); retryErr != nil {
return fmt.Errorf("expression %d (image): %w", ie.index+1, retryErr)
}
}
totalReplaced++
}
return sedOutputOK(ctx, u, id, sedOutputKV{"expressions", len(exprs)}, sedOutputKV{"replaced", totalReplaced})
}
// exprCategory describes how an expression should be processed in batch mode.
type exprCategory int
const (
exprCatPositional exprCategory = iota // ^, $, ^$ — sequential, changes doc state
exprCatImage // image replacement — individual API calls
exprCatCommand // d/a/i/y — run individually via runSingle
exprCatCell // table cell targeting
exprCatTableCreate // table creation spec
exprCatImagePattern // image pattern in search (!(n), ![re])
exprCatNative // plain text replace via native API
exprCatManual // requires manual formatting path
)
// classifyExprForBatch determines how an expression should be processed in batch mode.
func classifyExprForBatch(expr sedExpr) exprCategory {
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
(expr.pattern == "^$" || expr.pattern == "^" || expr.pattern == "$") {
return exprCatPositional
}
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
(strings.HasPrefix(expr.replacement, "![") ||
(expr.brace != nil && expr.brace.ImgRef != "")) {
return exprCatImage
}
if expr.command != 0 {
return exprCatCommand
}
if expr.cellRef != nil || expr.tableRef != 0 {
return exprCatCell
}
if parseTableCreate(expr.replacement) != nil || parseTableFromPipes(expr.replacement) != nil {
return exprCatTableCreate
}
if parseImageRefPattern(expr.pattern) != nil {
return exprCatImagePattern
}
if canUseNativeReplace(expr.replacement) && expr.global && expr.nthMatch <= 0 {
return exprCatNative
}
return exprCatManual
}
// isMergeOp returns true if the replacement string (lowered+trimmed) is a merge/unmerge/split op.
func isMergeOp(repl string) bool {
r := strings.TrimSpace(strings.ToLower(repl))
return r == mergeOp || r == unmergeOp || r == splitOp
}
// canBatchCell returns true if the expression is a simple whole-cell replacement
// that can be batched with adjacent same-table expressions.
func canBatchCell(ie indexedExpr) bool {
return ie.expr.cellRef != nil && ie.expr.pattern == "" &&
ie.expr.cellRef.row > 0 && ie.expr.cellRef.col > 0 &&
!isMergeOp(ie.expr.replacement) &&
ie.expr.cellRef.rowOp == "" && ie.expr.cellRef.colOp == ""
}
// processCellExprs handles cell-level expressions: merge/split, row/col ops, table ops,
// and batches consecutive whole-cell replacements for efficiency.
func (c *DocsSedCmd) processCellExprs(ctx context.Context, u *ui.UI, account, id string, cellExprs []indexedExpr) (int, error) {
totalReplaced := 0
i := 0
for i < len(cellExprs) {
ie := cellExprs[i]
// Merge/split/unmerge ops run individually
if isMergeOp(ie.expr.replacement) {
if err := c.runTableMerge(ctx, u, account, id, ie.expr); err != nil {
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
}
totalReplaced++
i++
continue
}
// Row/col ops run individually
if ie.expr.cellRef != nil && (ie.expr.cellRef.rowOp != "" || ie.expr.cellRef.colOp != "") {
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
}
totalReplaced++
i++
continue
}
// Table-level ops (delete table) run individually
if ie.expr.tableRef != 0 {
if err := c.runTableOp(ctx, u, account, id, ie.expr); err != nil {
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
}
totalReplaced++
i++
continue
}
// Collect consecutive whole-cell replacements for the same table
if canBatchCell(ie) {
tableIdx := ie.expr.cellRef.tableIndex
batch := []indexedExpr{ie}
j := i + 1
for j < len(cellExprs) && canBatchCell(cellExprs[j]) && cellExprs[j].expr.cellRef.tableIndex == tableIdx {
batch = append(batch, cellExprs[j])
j++
}
if len(batch) > 1 {
if err := c.runBatchCellReplace(ctx, u, account, id, batch); err != nil {
return totalReplaced, fmt.Errorf("cell batch (expressions %d-%d): %w", batch[0].index+1, batch[len(batch)-1].index+1, err)
}
totalReplaced += len(batch)
} else {
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
}
totalReplaced++
}
i = j
continue
}
// Wildcard or other cell ops run individually
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
}
totalReplaced++
i++
}
return totalReplaced, nil
}
func (c *DocsSedCmd) runNative(ctx context.Context, u *ui.UI, account, docID, pattern, replacement string) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
// Build native replace request with regex
requests := []*docs.Request{
{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: pattern,
MatchCase: true,
SearchByRegex: true,
},
ReplaceText: replacement,
},
},
}
resp, err := batchUpdate(ctx, docsSvc, docID, requests)
if err != nil {
return fmt.Errorf("update document: %w", err)
}
// Get replacement count from response
replaced := int64(0)
if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil {
replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged
}
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", replaced}, sedOutputKV{"native", true})
}

View File

@ -0,0 +1,391 @@
// Package cmd provides CLI commands for Google Docs operations.
package cmd
import (
"fmt"
"strconv"
"strings"
)
// indentNotSet is the sentinel value for braceExpr.Indent meaning "not specified".
// Any non-negative value means the indent level was explicitly set.
const indentNotSet = -1
// braceExpr represents a fully parsed brace expression from SEDMAT syntax.
// It captures all formatting, structural, and semantic attributes specified
// within a {flags} block in a replacement string.
type braceExpr struct {
// Boolean flags (nil = not set, *true = enabled, *false = negated)
Bold *bool // {b} = true, {!b} = false
Italic *bool
Underline *bool
Strike *bool
Code *bool
Sup *bool
Sub *bool
SmallCaps *bool
// Boolean flags with inline scoping
InlineSpans []inlineSpan // {b=Warning} → span with text + flags
// Value flags
Text string // t= (empty = not set, "$0" = default)
Color string // c= (hex or named, resolved to hex)
Bg string // z= (hex or named, resolved to hex)
Font string // f=
Size float64 // s= (0 = not set)
URL string // u=
Heading string // h= ("t","s","1"-"6","0", "" = not set)
Leading float64 // l= (0 = not set)
Align string // a= ("left","center","right","justify")
Opacity int // o= (0 = not set, 100 = default)
Indent int // n= (-1 = not set)
Kerning float64 // k=
Width int // x=
Height int // y=
// Paragraph spacing
SpacingAbove float64 // p= first value
SpacingBelow float64 // p= second value (or same as above if single)
SpacingSet bool // whether p= was specified
// Effect
Effect string // e=
// Columns
Cols int // cols= (0 = not set)
// Special flags
Reset bool // {0} — explicit full reset
NoReset bool // {!0} — opt out of implicit reset (additive mode)
Break string // += ("" = horizontal rule when + present, "p","c","s")
HasBreak bool // whether + was present
Comment string // "=text
Bookmark string // @=name
// Checkbox (tri-state)
Check *bool // nil = no checkbox, true = checked, false = unchecked
// Table of contents
TOC int // toc depth (0 = not set, -1 = unlimited)
HasTOC bool // whether toc was specified
// Image ref (pattern-side)
ImgRef string // img=
// Table ref (pattern-side)
TableRef string // T= (raw value for further parsing)
}
// inlineSpan represents an inline text span with associated boolean flags.
// Used for inline scoping like {b=Warning} where "Warning" is bolded inline.
type inlineSpan struct {
Text string // The text content of the span
Flags []string // Which boolean flags apply: "b", "i", "^", etc.
}
// boolFlagMap maps short and long names to canonical short names.
var boolFlagMap = map[string]string{
"b": "b",
"bold": "b",
"i": "i",
"italic": "i",
"_": "_",
"underline": "_",
"-": "-",
"strike": "-",
"#": "#",
"code": "#",
"^": "^",
"sup": "^",
",": ",",
"sub": ",",
"w": "w",
"smallcaps": "w",
}
// parseBraceExpr parses the content inside a brace expression.
// Input is the content between { }, e.g. for `{b c=red t=hello}` the input is `b c=red t=hello`.
func parseBraceExpr(s string) (*braceExpr, error) {
s = strings.TrimSpace(s)
if s == "" {
return &braceExpr{Indent: indentNotSet}, nil // -1 means not set
}
expr := &braceExpr{Indent: indentNotSet} // -1 means not set
// Handle comment first — it consumes everything after "=
if idx := strings.Index(s, `"=`); idx >= 0 {
expr.Comment = s[idx+2:]
s = strings.TrimSpace(s[:idx])
}
// Check for reset flag at start
if strings.HasPrefix(s, "0") {
expr.Reset = true
s = strings.TrimPrefix(s, "0")
s = strings.TrimSpace(s)
}
// Tokenize remaining content
tokens := tokenizeBraceContent(s)
for _, tok := range tokens {
if err := parseBraceToken(tok, expr); err != nil {
return nil, err
}
}
return expr, nil
}
// tokenizeBraceContent splits brace content into tokens.
// Tokens are space-separated, but values can contain spaces if quoted.
func tokenizeBraceContent(s string) []string {
var tokens []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inQuote:
if c == quoteChar {
inQuote = false
}
current.WriteByte(c)
case c == '"' || c == '\'':
inQuote = true
quoteChar = c
current.WriteByte(c)
case c == ' ' || c == '\t':
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
default:
current.WriteByte(c)
}
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
// parseBraceToken parses a single token and updates the braceExpr.
func parseBraceToken(tok string, expr *braceExpr) error {
// Handle break flag: + or +=value
if tok == "+" || strings.HasPrefix(tok, "+=") {
expr.HasBreak = true
if strings.HasPrefix(tok, "+=") {
expr.Break = tok[2:]
}
return nil
}
// Handle bookmark: @=name
if strings.HasPrefix(tok, "@=") {
expr.Bookmark = tok[2:]
return nil
}
// Handle negation: !flag
if strings.HasPrefix(tok, "!") {
flagName := tok[1:]
// {!0} = opt out of implicit reset (additive mode)
if flagName == "0" {
expr.NoReset = true
return nil
}
if canon, ok := boolFlagMap[flagName]; ok {
setBoolFlag(expr, canon, false)
return nil
}
return fmt.Errorf("unknown negated flag: %s", flagName)
}
// Check for key=value
eqIdx := strings.Index(tok, "=")
if eqIdx >= 0 {
key := tok[:eqIdx]
val := tok[eqIdx+1:]
return parseBraceKeyValue(key, val, expr)
}
// Bare flag (no =)
return parseBareFlag(tok, expr)
}
// parseBraceKeyValue handles key=value tokens.
func parseBraceKeyValue(key, val string, expr *braceExpr) error {
// Check if it's a boolean flag with inline scoping (e.g., b=Warning)
if canon, ok := boolFlagMap[key]; ok {
// This is inline scoping: {b=text} means bold just "text"
expr.InlineSpans = append(expr.InlineSpans, inlineSpan{
Text: val,
Flags: []string{canon},
})
return nil
}
// Value flags
switch key {
case "t", "text":
expr.Text = val
case "c", "color":
expr.Color = resolveColor(val)
case "z", "bg":
expr.Bg = resolveColor(val)
case "f", "font":
expr.Font = val
case "s", "size":
if n, err := strconv.ParseFloat(val, 64); err == nil && n > 0 {
expr.Size = n
}
case "u", "url":
expr.URL = val
case "h", "heading":
expr.Heading = val
case "l", "leading":
if n, err := strconv.ParseFloat(val, 64); err == nil && n > 0 {
expr.Leading = n
}
case "a", "align":
expr.Align = strings.ToLower(val)
case "o", "opacity":
if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 100 {
expr.Opacity = n
}
case "n", "indent":
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
expr.Indent = n
}
case "k", "kerning":
if n, err := strconv.ParseFloat(val, 64); err == nil {
expr.Kerning = n
}
case "x", "width":
if n, err := strconv.Atoi(val); err == nil && n > 0 {
expr.Width = n
}
case "y", "height":
if n, err := strconv.Atoi(val); err == nil && n > 0 {
expr.Height = n
}
case "p", "spacing":
parseSpacing(val, expr)
case "e", "effect":
expr.Effect = val
case "cols":
if n, err := strconv.Atoi(val); err == nil && n >= 1 {
expr.Cols = n
}
case "check":
switch strings.ToLower(val) {
case "y", "yes", strTrue, "1":
t := true
expr.Check = &t
case "n", "no", "false", "0":
f := false
expr.Check = &f
}
case "toc":
expr.HasTOC = true
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
expr.TOC = n
} else {
expr.TOC = -1 // unlimited
}
case "img":
expr.ImgRef = val
case "T":
expr.TableRef = val
default:
return fmt.Errorf("unknown key: %s", key)
}
return nil
}
// parseBareFlag handles bare flags without = (e.g., {b}, {check}, {toc}).
func parseBareFlag(tok string, expr *braceExpr) error {
// Check boolean flags
if canon, ok := boolFlagMap[tok]; ok {
setBoolFlag(expr, canon, true)
return nil
}
// Special bare flags
switch tok {
case "check":
// Bare check = unchecked checkbox
f := false
expr.Check = &f
case "toc":
expr.HasTOC = true
expr.TOC = -1 // unlimited
// Bare value flags reset to defaults (per SEDMAT spec)
case "t", "text":
expr.Text = "$0"
case "c", "color":
expr.Color = "#000000" // black
case "z", "bg":
expr.Bg = "" // clear (no background)
case "f", "font":
expr.Font = "Arial"
case "s", "size":
expr.Size = 11
case "h", "heading":
expr.Heading = "1" // default to HEADING_1
case "p", "spacing":
expr.SpacingSet = true
// Reset to defaults — SpacingAbove/Below stay 0
case "cols":
expr.Cols = 1
default:
return fmt.Errorf("unknown flag: %s", tok)
}
return nil
}
// parseSpacing parses the p= flag value: "12" (both) or "12,6" (above,below).
func parseSpacing(val string, expr *braceExpr) {
expr.SpacingSet = true
if idx := strings.Index(val, ","); idx >= 0 {
if above, err := strconv.ParseFloat(val[:idx], 64); err == nil {
expr.SpacingAbove = above
}
if below, err := strconv.ParseFloat(val[idx+1:], 64); err == nil {
expr.SpacingBelow = below
}
} else {
if v, err := strconv.ParseFloat(val, 64); err == nil {
expr.SpacingAbove = v
expr.SpacingBelow = v
}
}
}
// setBoolFlag sets a boolean flag on braceExpr to the given value.
func setBoolFlag(expr *braceExpr, canon string, val bool) {
switch canon {
case "b":
expr.Bold = &val
case "i":
expr.Italic = &val
case "_":
expr.Underline = &val
case "-":
expr.Strike = &val
case "#":
expr.Code = &val
case "^":
expr.Sup = &val
case ",":
expr.Sub = &val
case "w":
expr.SmallCaps = &val
}
}

View File

@ -0,0 +1,481 @@
// Package cmd provides CLI commands for Google Docs operations.
package cmd
import (
"strconv"
"strings"
"google.golang.org/api/docs/v1"
)
// buildBraceTextStyleRequests converts a braceExpr to UpdateTextStyle requests.
// It handles boolean flags (bold, italic, etc.), value flags (color, font, size),
// negation, and reset.
func buildBraceTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
if be == nil {
return nil
}
// Handle reset: explicit {0} OR implicit (default unless {!0}).
// Every brace expression implicitly resets all formatting first,
// making output deterministic regardless of inherited doc styles.
// Use {!0} to opt into additive mode (preserve existing styles).
if be.Reset || !be.NoReset {
return buildResetTextStyleRequests(be, start, end)
}
style := &docs.TextStyle{}
var fields []string
// Boolean flags
if be.Bold != nil {
style.Bold = *be.Bold
fields = append(fields, "bold")
}
if be.Italic != nil {
style.Italic = *be.Italic
fields = append(fields, "italic")
}
if be.Underline != nil {
style.Underline = *be.Underline
fields = append(fields, "underline")
}
if be.Strike != nil {
style.Strikethrough = *be.Strike
fields = append(fields, "strikethrough")
}
if be.SmallCaps != nil {
style.SmallCaps = *be.SmallCaps
fields = append(fields, "smallCaps")
}
if be.Sup != nil && *be.Sup {
style.BaselineOffset = "SUPERSCRIPT"
fields = append(fields, "baselineOffset")
}
if be.Sub != nil && *be.Sub {
style.BaselineOffset = "SUBSCRIPT"
fields = append(fields, "baselineOffset")
}
// Reset baseline if both sup and sub are explicitly false
if be.Sup != nil && !*be.Sup && be.Sub != nil && !*be.Sub {
style.BaselineOffset = "NONE"
fields = append(fields, "baselineOffset")
}
// Code flag: monospace font + grey background
if be.Code != nil && *be.Code {
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: "Courier New"}
style.BackgroundColor = greyColor(codeBackgroundGrey)
fields = append(fields, "weightedFontFamily", "backgroundColor")
}
// Value flags
if be.Font != "" {
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: be.Font}
fields = append(fields, "weightedFontFamily")
}
if be.Size > 0 {
style.FontSize = &docs.Dimension{Magnitude: be.Size, Unit: "PT"}
fields = append(fields, "fontSize")
}
if be.Color != "" {
if r, g, b, ok := parseHexColor(be.Color); ok {
style.ForegroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
fields = append(fields, "foregroundColor")
}
}
if be.Bg != "" {
if r, g, b, ok := parseHexColor(be.Bg); ok {
style.BackgroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
fields = append(fields, "backgroundColor")
}
}
if be.URL != "" {
// Handle bookmark links (#name) vs regular URLs
if strings.HasPrefix(be.URL, "#") {
style.Link = &docs.Link{BookmarkId: be.URL[1:]}
} else {
style.Link = &docs.Link{Url: be.URL}
}
fields = append(fields, "link")
}
if len(fields) == 0 {
return nil
}
return []*docs.Request{
{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
TextStyle: style,
Fields: strings.Join(fields, ","),
},
},
}
}
// resetFieldsStr is the pre-joined field mask for resetting all text formatting.
// Package-level to avoid per-call allocation and string joining.
var resetFieldsStr = strings.Join([]string{
"bold", "italic", "underline", "strikethrough", "smallCaps",
"baselineOffset", "foregroundColor", "backgroundColor",
"fontSize", "weightedFontFamily", "link",
}, ",")
// buildResetTextStyleRequests builds requests to clear all formatting, then apply any
// additional flags specified after the reset (e.g., {0 b} = reset then bold).
func buildResetTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
requests := []*docs.Request{
{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
TextStyle: &docs.TextStyle{}, // Empty style = reset
Fields: resetFieldsStr,
},
},
}
// Now apply any flags that were set alongside the reset
// Create a copy without Reset flag and with NoReset to avoid recursion
postReset := *be
postReset.Reset = false
postReset.NoReset = true
if braceExprHasTextFormat(&postReset) {
requests = append(requests, buildBraceTextStyleRequests(&postReset, start, end)...)
}
return requests
}
// braceExprHasTextFormat returns true if the braceExpr has any text-level formatting.
func braceExprHasTextFormat(be *braceExpr) bool {
if be == nil {
return false
}
return be.Bold != nil || be.Italic != nil || be.Underline != nil ||
be.Strike != nil || be.Code != nil || be.Sup != nil ||
be.Sub != nil || be.SmallCaps != nil || be.Color != "" ||
be.Bg != "" || be.Font != "" || be.Size > 0 || be.URL != ""
}
// buildBraceParagraphStyleRequests converts a braceExpr to paragraph-level requests.
// Handles headings, alignment, indent, leading, spacing, bullets.
func buildBraceParagraphStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
if be == nil {
return nil
}
var requests []*docs.Request
// Build paragraph style
paraStyle := &docs.ParagraphStyle{}
var paraFields []string
// Heading
if be.Heading != "" {
namedStyle := resolveHeading(be.Heading)
paraStyle.NamedStyleType = namedStyle
paraFields = append(paraFields, "namedStyleType")
}
// Alignment
if be.Align != "" {
paraStyle.Alignment = resolveAlign(be.Align)
paraFields = append(paraFields, "alignment")
}
// Indent level (converted to indentStart in points)
if be.Indent >= 0 {
// Each indent level = 36pt (standard Google Docs indent)
indentPt := float64(be.Indent) * indentPointsPerLevel
paraStyle.IndentStart = &docs.Dimension{Magnitude: indentPt, Unit: "PT"}
paraFields = append(paraFields, "indentStart")
}
// Line height / leading
if be.Leading > 0 {
paraStyle.LineSpacing = be.Leading * 100 // 1.5 → 150
paraFields = append(paraFields, "lineSpacing")
}
// Paragraph spacing (above/below)
if be.SpacingSet {
if be.SpacingAbove > 0 || be.SpacingBelow > 0 {
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: be.SpacingAbove, Unit: "PT"}
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: be.SpacingBelow, Unit: "PT"}
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
} else {
// Reset spacing to zero
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: 0, Unit: "PT"}
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: 0, Unit: "PT"}
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
}
}
if len(paraFields) > 0 {
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
ParagraphStyle: paraStyle,
Fields: strings.Join(paraFields, ","),
},
})
}
return requests
}
// buildBraceInlineRequests handles inline scoping — multiple styled spans within one replacement.
// Each span has its own start/end positions and formatting.
func buildBraceInlineRequests(spans []*braceSpan, baseIndex int64) []*docs.Request {
if len(spans) == 0 {
return nil
}
var requests []*docs.Request
for _, span := range spans {
if span.IsGlobal || span.Expr == nil {
continue // Global spans handled separately
}
start := baseIndex + int64(span.Start)
end := baseIndex + int64(span.End)
if end <= start {
continue
}
requests = append(requests, buildBraceTextStyleRequests(span.Expr, start, end)...)
}
return requests
}
// buildBraceBreakRequests handles break flags (+, +=p, +=c, +=s).
// Returns requests to insert horizontal rules, page/column/section breaks.
func buildBraceBreakRequests(be *braceExpr, insertIdx int64) []*docs.Request {
if be == nil || !be.HasBreak {
return nil
}
var requests []*docs.Request
switch be.Break {
case "p": // Page break
requests = append(requests, &docs.Request{
InsertPageBreak: &docs.InsertPageBreakRequest{
Location: &docs.Location{Index: insertIdx},
},
})
case "c": // Column break
// Column break is just a special character insert
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: "\v", // Vertical tab = column break in Docs
},
})
case "s": // Section break
requests = append(requests, &docs.Request{
InsertSectionBreak: &docs.InsertSectionBreakRequest{
Location: &docs.Location{Index: insertIdx},
SectionType: "NEXT_PAGE",
},
})
default: // "" = horizontal rule
// Insert newline with bottom border
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: "\n",
},
})
requests = append(requests, buildHruleBorderRequest(insertIdx, insertIdx+1))
}
return requests
}
// braceExprToFormats converts a braceExpr to the existing format string slice
// for backward compatibility with existing code paths.
func braceExprToFormats(be *braceExpr) []string {
if be == nil {
return nil
}
var formats []string
// Boolean flags
if be.Bold != nil && *be.Bold {
formats = append(formats, "bold")
}
if be.Italic != nil && *be.Italic {
formats = append(formats, "italic")
}
if be.Underline != nil && *be.Underline {
formats = append(formats, "underline")
}
if be.Strike != nil && *be.Strike {
formats = append(formats, "strikethrough")
}
if be.Code != nil && *be.Code {
formats = append(formats, "code")
}
if be.Sup != nil && *be.Sup {
formats = append(formats, "superscript")
}
if be.Sub != nil && *be.Sub {
formats = append(formats, "subscript")
}
if be.SmallCaps != nil && *be.SmallCaps {
formats = append(formats, "smallcaps")
}
// Value flags
if be.Font != "" {
formats = append(formats, "font:"+be.Font)
}
if be.Size > 0 {
formats = append(formats, "size:"+formatFloat(be.Size))
}
if be.Color != "" {
formats = append(formats, "color:"+be.Color)
}
if be.Bg != "" {
formats = append(formats, "bg:"+be.Bg)
}
if be.URL != "" {
formats = append(formats, "link:"+be.URL)
}
// Heading
if be.Heading != "" {
level := be.Heading
switch level {
case "t":
formats = append(formats, "title")
case "s":
formats = append(formats, "subtitle")
case "0":
formats = append(formats, "normal")
default:
formats = append(formats, "heading"+level)
}
}
// Alignment
if be.Align != "" {
formats = append(formats, "align:"+be.Align)
}
return formats
}
// formatFloat formats a float64 without unnecessary trailing zeros.
func formatFloat(f float64) string {
// Check if it's a whole number
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
// Format with precision, trim trailing zeros
s := strconv.FormatFloat(f, 'f', -1, 64)
return s
}
// hasBraceTextFormat checks if braceExpr has formatting that requires text styling.
func hasBraceTextFormat(be *braceExpr) bool {
return braceExprHasTextFormat(be)
}
// hasBraceParagraphFormat checks if braceExpr has formatting that requires paragraph styling.
func hasBraceParagraphFormat(be *braceExpr) bool {
if be == nil {
return false
}
return be.Heading != "" || be.Align != "" || be.Indent >= 0 ||
be.Leading > 0 || be.SpacingSet
}
// mergeBraceSpans merges multiple braceSpans into a single braceExpr for global formatting.
// Only global spans (those that apply to the entire match) are merged; non-global spans
// represent inline-scoped formatting (e.g., {b=Warning}) and are handled separately
// by buildBraceInlineRequests, which applies them at their specific positions.
func mergeBraceSpans(spans []*braceSpan) *braceExpr {
merged := &braceExpr{Indent: indentNotSet}
for _, span := range spans {
if span.IsGlobal && span.Expr != nil {
// Copy global flags to merged
if span.Expr.Bold != nil {
merged.Bold = span.Expr.Bold
}
if span.Expr.Italic != nil {
merged.Italic = span.Expr.Italic
}
if span.Expr.Underline != nil {
merged.Underline = span.Expr.Underline
}
if span.Expr.Strike != nil {
merged.Strike = span.Expr.Strike
}
if span.Expr.Code != nil {
merged.Code = span.Expr.Code
}
if span.Expr.Sup != nil {
merged.Sup = span.Expr.Sup
}
if span.Expr.Sub != nil {
merged.Sub = span.Expr.Sub
}
if span.Expr.SmallCaps != nil {
merged.SmallCaps = span.Expr.SmallCaps
}
if span.Expr.Color != "" {
merged.Color = span.Expr.Color
}
if span.Expr.Bg != "" {
merged.Bg = span.Expr.Bg
}
if span.Expr.Font != "" {
merged.Font = span.Expr.Font
}
if span.Expr.Size > 0 {
merged.Size = span.Expr.Size
}
if span.Expr.URL != "" {
merged.URL = span.Expr.URL
}
if span.Expr.Heading != "" {
merged.Heading = span.Expr.Heading
}
if span.Expr.Align != "" {
merged.Align = span.Expr.Align
}
if span.Expr.Leading > 0 {
merged.Leading = span.Expr.Leading
}
if span.Expr.SpacingSet {
merged.SpacingSet = true
merged.SpacingAbove = span.Expr.SpacingAbove
merged.SpacingBelow = span.Expr.SpacingBelow
}
if span.Expr.Indent >= 0 {
merged.Indent = span.Expr.Indent
}
if span.Expr.Reset {
merged.Reset = true
}
if span.Expr.HasBreak {
merged.HasBreak = true
merged.Break = span.Expr.Break
}
}
}
return merged
}
// (Legacy attrsTobraceExpr removed — sedAttrs no longer exists)

View File

@ -0,0 +1,296 @@
package cmd
import (
"strings"
)
// braceSpan represents a positioned brace expression within a replacement string.
// It tracks where in the output text the formatting should be applied.
type braceSpan struct {
Expr *braceExpr // The parsed brace expression
Start int // Start position in the cleaned output text
End int // End position in the cleaned output text
IsGlobal bool // True if this applies to the whole match (e.g., {b} alone)
RawBraces string // Original {content} for debugging
}
// findBraceExprs finds all `{...}` groups in a replacement string and returns
// the cleaned text plus positioned spans. Handles:
// - `{b}` as entire replacement → whole-match formatting
// - `{b=text}` inline → inline span at position
// - Multiple brace groups: `H{,=2}O` → "H2O" with subscript on "2"
// - Escaped braces: `\{` and `\}` are literals
func findBraceExprs(replacement string) (string, []*braceSpan) {
if !strings.Contains(replacement, "{") {
return replacement, nil
}
var spans []*braceSpan
var cleaned strings.Builder
cleanedPos := 0
i := 0
for i < len(replacement) {
// Handle escaped braces
if i+1 < len(replacement) && replacement[i] == '\\' {
if replacement[i+1] == '{' || replacement[i+1] == '}' {
cleaned.WriteByte(replacement[i+1])
cleanedPos++
i += 2
continue
}
// Other escape sequences
cleaned.WriteByte(replacement[i])
cleanedPos++
i++
continue
}
// Look for opening brace
if replacement[i] == '{' {
// Find matching closing brace (handle nesting for error detection)
closeIdx := findMatchingBrace(replacement, i)
if closeIdx < 0 {
// Unmatched brace — treat as literal
cleaned.WriteByte('{')
cleanedPos++
i++
continue
}
braceContent := replacement[i+1 : closeIdx]
rawBraces := replacement[i : closeIdx+1]
expr, err := parseBraceExpr(braceContent)
if err != nil {
// Parse error — treat as literal
cleaned.WriteByte('{')
cleanedPos++
i++
continue
}
// Determine span behavior
span := &braceSpan{
Expr: expr,
Start: cleanedPos,
RawBraces: rawBraces,
}
// Check if this has inline spans with text
if len(expr.InlineSpans) > 0 {
// Handle inline scoping: write the span text
for _, is := range expr.InlineSpans {
spanStart := cleanedPos
cleaned.WriteString(is.Text)
cleanedPos += len(is.Text)
span.End = cleanedPos
// Create a span for each inline text
inlineExpr := &braceExpr{Indent: indentNotSet}
for _, flag := range is.Flags {
setBoolFlag(inlineExpr, flag, true)
}
spans = append(spans, &braceSpan{
Expr: inlineExpr,
Start: spanStart,
End: cleanedPos,
RawBraces: rawBraces,
})
}
i = closeIdx + 1
continue
}
// Check if Text is set explicitly
if expr.Text != "" && expr.Text != "$0" {
// Write the explicit text
span.Start = cleanedPos
cleaned.WriteString(expr.Text)
cleanedPos += len(expr.Text)
span.End = cleanedPos
spans = append(spans, span)
i = closeIdx + 1
continue
}
// Check if this is a global expression (applies to whole match)
// Global if:
// 1. The brace is the entire replacement, or
// 2. Text is set to $0 (explicit whole-match), or
// 3. No inline spans and no explicit text (implicit whole-match)
isGlobal := isGlobalBraceExpr(replacement, i, closeIdx)
if isGlobal {
span.IsGlobal = true
span.End = -1 // Will be set by caller to match length
}
spans = append(spans, span)
i = closeIdx + 1
continue
}
// Regular character
cleaned.WriteByte(replacement[i])
cleanedPos++
i++
}
return cleaned.String(), spans
}
// findMatchingBrace finds the index of the closing brace matching the one at pos.
// Returns -1 if no matching brace is found. Correctly handles escaped braces (\{ and \}).
func findMatchingBrace(s string, pos int) int {
if pos >= len(s) || s[pos] != '{' {
return -1
}
depth := 1
for i := pos + 1; i < len(s); i++ {
if s[i] == '\\' {
i++ // skip next character (escaped)
continue
}
switch s[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// isGlobalBraceExpr determines if a brace expression should apply to the whole match.
// Returns true if:
// 1. The brace expression is the entire replacement (no surrounding text), OR
// 2. The brace is a trailing-only expression: text{flags} with nothing after.
// In SEDMAT, `text{b c=red}` means "format all of 'text' with bold+red".
func isGlobalBraceExpr(replacement string, openIdx, closeIdx int) bool {
before := strings.TrimSpace(replacement[:openIdx])
after := strings.TrimSpace(replacement[closeIdx+1:])
// Standalone brace (only thing in replacement) — global
if before == "" && after == "" {
return true
}
// Leading brace ({f=Georgia}text) — applies to all following text
if before == "" && after != "" {
return true
}
// Trailing brace (text{b}) — applies to all preceding text
if after == "" && before != "" {
return true
}
return false
}
// hasBraceFormatting returns true if the replacement contains brace formatting.
// Used to determine if fast-path native replacement can be used.
func hasBraceFormatting(replacement string) bool {
// Look for { not preceded by \
for i := 0; i < len(replacement); i++ {
if replacement[i] == '{' {
if i == 0 || replacement[i-1] != '\\' {
// Check if there's content and a closing brace
closeIdx := findMatchingBrace(replacement, i)
if closeIdx > i+1 {
// Has non-empty brace content
content := replacement[i+1 : closeIdx]
// Verify it looks like a brace expr (has known flags or key=value)
if looksLikeBraceExpr(content) {
return true
}
}
}
}
}
return false
}
// valueKeySet contains all value flag prefixes for fast brace expression detection.
// Package-level to avoid per-call allocation.
var valueKeySet = func() map[string]bool {
keys := []string{
"t=", "text=", "c=", "color=", "z=", "bg=", "f=", "font=",
"s=", "size=", "u=", "url=", "h=", "heading=", "l=", "leading=",
"a=", "align=", "o=", "opacity=", "n=", "indent=", "k=", "kerning=",
"x=", "width=", "y=", "height=", "p=", "spacing=", "e=", "effect=",
"cols=", "check", "toc", "img=", "T=", "@=", `"=`,
}
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}()
// looksLikeBraceExpr returns true if the content looks like a valid brace expression.
// Used for heuristic detection to distinguish {formatting} from literal braces.
func looksLikeBraceExpr(content string) bool {
content = strings.TrimSpace(content)
if content == "" {
return false
}
// Check for known patterns
// Reset
if content == "0" || strings.HasPrefix(content, "0 ") {
return true
}
// Boolean flags
for flag := range boolFlagMap {
if content == flag || strings.HasPrefix(content, flag+" ") || strings.HasPrefix(content, flag+"=") {
return true
}
if content == "!"+flag || strings.HasPrefix(content, "!"+flag+" ") {
return true
}
}
// Value flags — check if content contains any known key prefix
for key := range valueKeySet {
if strings.Contains(content, key) {
return true
}
}
// Break flag
if content == "+" || strings.HasPrefix(content, "+=") {
return true
}
return false
}
// braceExprHasAnyFormat returns true if the expression sets any formatting.
// Used to filter out empty or no-op expressions.
func braceExprHasAnyFormat(expr *braceExpr) bool {
if expr == nil {
return false
}
// Check boolean flags
if expr.Bold != nil || expr.Italic != nil || expr.Underline != nil ||
expr.Strike != nil || expr.Code != nil || expr.Sup != nil ||
expr.Sub != nil || expr.SmallCaps != nil {
return true
}
// Check inline spans
if len(expr.InlineSpans) > 0 {
return true
}
// Check value flags (note: Indent -1 means not set, 0+ means explicitly set)
if expr.Text != "" || expr.Color != "" || expr.Bg != "" || expr.Font != "" ||
expr.Size > 0 || expr.URL != "" || expr.Heading != "" || expr.Leading > 0 ||
expr.Align != "" || expr.Opacity > 0 || expr.Indent > indentNotSet || expr.Kerning != 0 ||
expr.Width > 0 || expr.Height > 0 || expr.SpacingSet || expr.Effect != "" ||
expr.Cols > 0 {
return true
}
// Check special flags
if expr.Reset || expr.HasBreak || expr.Comment != "" || expr.Bookmark != "" ||
expr.Check != nil || expr.HasTOC || expr.ImgRef != "" || expr.TableRef != "" {
return true
}
return false
}

View File

@ -0,0 +1,584 @@
// Package cmd provides CLI commands for Google Docs operations.
package cmd
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// braceTableRef represents a parsed {T=...} table reference for pattern-side addressing.
// It captures table index, cell references, ranges, wildcards, and row/col operations.
type braceTableRef struct {
TableIndex int // 1-indexed, negative from end, 0 for * (all tables), math.MinInt32 reserved
IsCreate bool // {T=3x4} — table creation spec
CreateRows int
CreateCols int
HasHeader bool // {T=3x4:header}
// Cell addressing
Cell *tableCellRef // reuse existing struct for single cell
Row int // 1-indexed row for cell ref, 0 for wildcard
Col int // 1-indexed col for cell ref, 0 for wildcard
IsExcel bool // true if Excel-style (A1), false if row,col
ExcelCell string // original Excel ref (e.g., "A1")
// Range addressing
HasRange bool // A1:C3 or 1,1:3,3
EndRow int // end row for range
EndCol int // end col for range
// Wildcards
IsAllCells bool // {T=1!*} — all cells in table
RowWild bool // {T=1!1,*} — entire row
ColWild bool // {T=1!*,2} — entire column
// Row/column operations
RowOp string // "+2" (insert before 2), "$+" (append), "2" (delete row 2), "-1" (delete last)
ColOp string // same semantics for columns
}
// braceImgRef represents a parsed {img=...} image reference for pattern-side addressing.
type braceImgRef struct {
Index int // 1-indexed, negative from end, 0 for pattern match
IsAll bool // {img=*}
Pattern string // regex pattern for alt text matching
Regex *regexp.Regexp // compiled pattern (nil if positional)
}
// parseBraceTableRef parses a {T=...} table reference spec.
// Supports: {T=1}, {T=-1}, {T=*}, {T=3x4}, {T=3x4:header},
// {T=1!A1}, {T=1!1,2}, {T=1!1,*}, {T=1!*,2}, {T=1!*},
// {T=1!A1:C3}, {T=1!row=+2}, {T=1!row=$+}, {T=1!col=+3}, etc.
func parseBraceTableRef(spec string) (*braceTableRef, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, fmt.Errorf("empty table spec")
}
ref := &braceTableRef{}
// Check for table creation: NxM or NxM:header
if isTableCreateSpec(spec) {
return parseTableCreateBrace(spec)
}
// Split by ! to separate table index from cell spec
parts := strings.SplitN(spec, "!", 2)
tableSpec := parts[0]
var cellSpec string
if len(parts) > 1 {
cellSpec = parts[1]
}
// Parse table index
if tableSpec == "*" {
ref.TableIndex = 0 // 0 means all tables
} else {
idx, err := strconv.Atoi(tableSpec)
if err != nil {
return nil, fmt.Errorf("invalid table index %q: %w", tableSpec, err)
}
if idx == 0 {
return nil, fmt.Errorf("table index cannot be 0; use * for all")
}
ref.TableIndex = idx
}
// If no cell spec, we're done
if cellSpec == "" {
return ref, nil
}
// Parse cell spec
return parseBraceCellSpec(ref, cellSpec)
}
// isTableCreateSpec checks if spec looks like a table creation (NxM or NxM:header).
func isTableCreateSpec(spec string) bool {
// Must contain 'x' and no '!'
if strings.Contains(spec, "!") {
return false
}
lower := strings.ToLower(spec)
if !strings.Contains(lower, "x") {
return false
}
// Must start with digit
if len(spec) == 0 || spec[0] < '0' || spec[0] > '9' {
return false
}
return true
}
// parseTableCreateBrace parses {T=3x4} or {T=3x4:header}.
func parseTableCreateBrace(spec string) (*braceTableRef, error) {
ref := &braceTableRef{IsCreate: true}
// Check for :header suffix
if idx := strings.Index(spec, ":"); idx >= 0 {
suffix := strings.ToLower(strings.TrimSpace(spec[idx+1:]))
if suffix != "header" {
return nil, fmt.Errorf("invalid table create suffix %q (expected 'header')", suffix)
}
ref.HasHeader = true
spec = spec[:idx]
}
// Parse RxC
lower := strings.ToLower(spec)
parts := strings.SplitN(lower, "x", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid table create spec %q", spec)
}
rows, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || rows < 1 || rows > 100 {
return nil, fmt.Errorf("invalid row count in %q (must be 1-100)", spec)
}
cols, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || cols < 1 || cols > 26 {
return nil, fmt.Errorf("invalid column count in %q (must be 1-26)", spec)
}
ref.CreateRows = rows
ref.CreateCols = cols
return ref, nil
}
// parseBraceCellSpec parses the cell specification after the ! separator.
func parseBraceCellSpec(ref *braceTableRef, spec string) (*braceTableRef, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return ref, nil
}
// Check for wildcard: *
if spec == "*" {
ref.IsAllCells = true
return ref, nil
}
// Check for row operation: row=+2, row=$+, row=2, row=-1
if strings.HasPrefix(spec, "row=") {
return parseRowOp(ref, spec[4:])
}
// Check for column operation: col=+3, col=$+, col=3, col=-1
if strings.HasPrefix(spec, "col=") {
return parseColOp(ref, spec[4:])
}
// Check for range: A1:C3 or 1,1:3,3
if strings.Contains(spec, ":") {
return parseCellRange(ref, spec)
}
// Check for row,col format with wildcards
if strings.Contains(spec, ",") {
return parseRowColRef(ref, spec)
}
// Try Excel-style reference (A1, B12, etc.)
row, col, ok := parseExcelRef(spec)
if ok {
ref.Row = row
ref.Col = col
ref.IsExcel = true
ref.ExcelCell = spec
return ref, nil
}
return nil, fmt.Errorf("invalid cell spec %q", spec)
}
// parseRowOp parses row operations: +2 (insert before 2), $+ (append), 2 (delete), -1 (delete last).
func parseRowOp(ref *braceTableRef, val string) (*braceTableRef, error) {
val = strings.TrimSpace(val)
if val == "" {
return nil, fmt.Errorf("empty row operation")
}
ref.RowOp = val
return ref, nil
}
// parseColOp parses column operations: +3 (insert before 3), $+ (append), 3 (delete), -1 (delete last).
func parseColOp(ref *braceTableRef, val string) (*braceTableRef, error) {
val = strings.TrimSpace(val)
if val == "" {
return nil, fmt.Errorf("empty column operation")
}
ref.ColOp = val
return ref, nil
}
// parseCellRange parses A1:C3 or 1,1:3,3 range syntax.
func parseCellRange(ref *braceTableRef, spec string) (*braceTableRef, error) {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range %q", spec)
}
startSpec := strings.TrimSpace(parts[0])
endSpec := strings.TrimSpace(parts[1])
// Parse start cell
startRow, startCol, err := parseCellCoord(startSpec)
if err != nil {
return nil, fmt.Errorf("invalid range start %q: %w", startSpec, err)
}
// Parse end cell
endRow, endCol, err := parseCellCoord(endSpec)
if err != nil {
return nil, fmt.Errorf("invalid range end %q: %w", endSpec, err)
}
ref.Row = startRow
ref.Col = startCol
ref.EndRow = endRow
ref.EndCol = endCol
ref.HasRange = true
return ref, nil
}
// parseCellCoord parses a single cell coordinate (A1 or 1,2 format).
func parseCellCoord(s string) (row, col int, err error) {
s = strings.TrimSpace(s)
// Try row,col format
if strings.Contains(s, ",") {
parts := strings.SplitN(s, ",", 2)
row, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, fmt.Errorf("invalid row: %w", err)
}
col, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, fmt.Errorf("invalid col: %w", err)
}
return row, col, nil
}
// Try Excel-style
row, col, ok := parseExcelRef(s)
if !ok {
return 0, 0, fmt.Errorf("invalid cell reference %q", s)
}
return row, col, nil
}
// parseRowColRef parses R,C format with wildcard support.
func parseRowColRef(ref *braceTableRef, spec string) (*braceTableRef, error) {
parts := strings.SplitN(spec, ",", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid row,col spec %q", spec)
}
rowStr := strings.TrimSpace(parts[0])
colStr := strings.TrimSpace(parts[1])
// Parse row
if rowStr == "*" {
ref.ColWild = true // *,N means entire column N
ref.Row = 0
} else {
row, err := strconv.Atoi(rowStr)
if err != nil {
return nil, fmt.Errorf("invalid row %q: %w", rowStr, err)
}
ref.Row = row
}
// Parse col
if colStr == "*" {
ref.RowWild = true // N,* means entire row N
ref.Col = 0
} else {
col, err := strconv.Atoi(colStr)
if err != nil {
return nil, fmt.Errorf("invalid col %q: %w", colStr, err)
}
ref.Col = col
}
return ref, nil
}
// parseBraceImgRef parses a {img=...} image reference spec.
// Supports: {img=1}, {img=-1}, {img=*}, {img=pattern}
func parseBraceImgRef(spec string) (*braceImgRef, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, fmt.Errorf("empty image spec")
}
ref := &braceImgRef{}
// Check for all images
if spec == "*" {
ref.IsAll = true
return ref, nil
}
// Try to parse as integer (position)
if idx, err := strconv.Atoi(spec); err == nil {
ref.Index = idx
return ref, nil
}
// Treat as regex pattern for alt text matching
regex, err := regexp.Compile(spec)
if err != nil {
return nil, fmt.Errorf("invalid image pattern %q: %w", spec, err)
}
ref.Pattern = spec
ref.Regex = regex
return ref, nil
}
// detectBracePattern detects if a pattern starts with {T=...} or {img=...}.
// Returns the remaining pattern (for cell-level find/replace) and the parsed refs.
// For example: "{T=1!A1}old" returns ("old", braceTableRef, nil, nil)
func detectBracePattern(pattern string) (remaining string, tableRef *braceTableRef, imgRef *braceImgRef, err error) {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return "", nil, nil, nil
}
// Must start with {
if !strings.HasPrefix(pattern, "{") {
return pattern, nil, nil, nil
}
// Find closing brace
closeIdx := findMatchingBrace(pattern, 0)
if closeIdx < 0 {
return pattern, nil, nil, nil
}
braceContent := pattern[1:closeIdx]
remaining = strings.TrimSpace(pattern[closeIdx+1:])
// Check for T= (table reference)
if strings.HasPrefix(braceContent, "T=") {
tableSpec := strings.TrimPrefix(braceContent, "T=")
tableRef, err = parseBraceTableRef(tableSpec)
if err != nil {
return "", nil, nil, fmt.Errorf("parse table ref: %w", err)
}
return remaining, tableRef, nil, nil
}
// Check for img= (image reference)
if strings.HasPrefix(braceContent, "img=") {
imgSpec := strings.TrimPrefix(braceContent, "img=")
imgRef, err = parseBraceImgRef(imgSpec)
if err != nil {
return "", nil, nil, fmt.Errorf("parse image ref: %w", err)
}
return remaining, nil, imgRef, nil
}
// Not a pattern-side brace ref
return pattern, nil, nil, nil
}
// braceTableToSedExpr bridges braceTableRef to the existing sedExpr fields
// so existing table machinery (runTableCellReplace, runTableRowColOp, etc.) can handle it.
func braceTableToSedExpr(bt *braceTableRef, expr *sedExpr) {
if bt == nil {
return
}
// Handle table-level reference (no cell spec)
if !bt.IsAllCells && !bt.HasRange && bt.Row == 0 && bt.Col == 0 &&
bt.RowOp == "" && bt.ColOp == "" && !bt.IsCreate {
// Bare table reference: {T=1}, {T=-1}, {T=*}
if bt.TableIndex == 0 {
// math.MinInt32 signals "all tables" in the existing code
expr.tableRef = -2147483648 // math.MinInt32
} else {
expr.tableRef = bt.TableIndex
}
return
}
// Handle table creation
if bt.IsCreate {
// Table creation is handled differently — set a marker
// The actual creation happens via replacement parsing
return
}
// Build tableCellRef for cell-level operations
cellRef := &tableCellRef{
tableIndex: bt.TableIndex,
}
// Handle row/col operations
if bt.RowOp != "" {
cellRef.rowOp, cellRef.opTarget = parseRowColOpValue(bt.RowOp)
}
if bt.ColOp != "" {
cellRef.colOp, cellRef.opTarget = parseRowColOpValue(bt.ColOp)
}
// Handle cell addressing
switch {
case bt.IsAllCells:
// All cells: row=0, col=0 (wildcard in existing code)
cellRef.row = 0
cellRef.col = 0
case bt.RowWild:
// Entire row: col=0
cellRef.row = bt.Row
cellRef.col = 0
case bt.ColWild:
// Entire column: row=0
cellRef.row = 0
cellRef.col = bt.Col
case bt.HasRange:
// Range: set start and end
cellRef.row = bt.Row
cellRef.col = bt.Col
cellRef.endRow = bt.EndRow
cellRef.endCol = bt.EndCol
default:
// Single cell
cellRef.row = bt.Row
cellRef.col = bt.Col
}
expr.cellRef = cellRef
}
// parseRowColOpValue converts brace op string to existing code's format.
// "+2" → ("insert", 2), "$+" → ("append", 0), "2" → ("delete", 2), "-1" → ("delete", -1)
func parseRowColOpValue(op string) (operation string, target int) {
op = strings.TrimSpace(op)
if op == "$+" {
return opAppend, 0
}
if strings.HasPrefix(op, "+") {
n, err := strconv.Atoi(op[1:])
if err == nil {
return opInsert, n
}
return "", 0
}
// Plain number (positive or negative) = delete
n, err := strconv.Atoi(op)
if err == nil {
return opDelete, n
}
return "", 0
}
// braceImgToImageRefPattern bridges braceImgRef to the existing ImageRefPattern struct.
func braceImgToImageRefPattern(bi *braceImgRef) *ImageRefPattern {
if bi == nil {
return nil
}
ref := &ImageRefPattern{}
if bi.IsAll {
ref.ByPosition = true
ref.AllImages = true
return ref
}
if bi.Index != 0 {
ref.ByPosition = true
ref.Position = bi.Index
return ref
}
if bi.Pattern != "" && bi.Regex != nil {
ref.ByAlt = true
ref.AltRegex = bi.Regex
return ref
}
return nil
}
// braceTableToTableCreateSpec converts a braceTableRef creation spec to tableCreateSpec.
func braceTableToTableCreateSpec(bt *braceTableRef) *tableCreateSpec {
if bt == nil || !bt.IsCreate {
return nil
}
return &tableCreateSpec{
rows: bt.CreateRows,
cols: bt.CreateCols,
header: bt.HasHeader,
}
}
// String returns a debug representation of braceTableRef.
func (bt *braceTableRef) String() string {
if bt == nil {
return "<nil>"
}
var parts []string
if bt.IsCreate {
parts = append(parts, fmt.Sprintf("create:%dx%d", bt.CreateRows, bt.CreateCols))
if bt.HasHeader {
parts = append(parts, "header")
}
return "{T=" + strings.Join(parts, " ") + "}"
}
if bt.TableIndex == 0 {
parts = append(parts, "table:*")
} else {
parts = append(parts, fmt.Sprintf("table:%d", bt.TableIndex))
}
switch {
case bt.IsAllCells:
parts = append(parts, "cells:*")
case bt.HasRange:
parts = append(parts, fmt.Sprintf("range:[%d,%d:%d,%d]", bt.Row, bt.Col, bt.EndRow, bt.EndCol))
case bt.RowWild:
parts = append(parts, fmt.Sprintf("row:%d,*", bt.Row))
case bt.ColWild:
parts = append(parts, fmt.Sprintf("col:*,%d", bt.Col))
case bt.Row > 0 || bt.Col > 0:
parts = append(parts, fmt.Sprintf("cell:[%d,%d]", bt.Row, bt.Col))
}
if bt.RowOp != "" {
parts = append(parts, "rowOp:"+bt.RowOp)
}
if bt.ColOp != "" {
parts = append(parts, "colOp:"+bt.ColOp)
}
return "{T=" + strings.Join(parts, " ") + "}"
}
// String returns a debug representation of braceImgRef.
func (bi *braceImgRef) String() string {
if bi == nil {
return "<nil>"
}
if bi.IsAll {
return "{img=*}"
}
if bi.Index != 0 {
return fmt.Sprintf("{img=%d}", bi.Index)
}
if bi.Pattern != "" {
return fmt.Sprintf("{img=%s}", bi.Pattern)
}
return "{img=?}"
}

View File

@ -0,0 +1,131 @@
package cmd
import (
"fmt"
"strings"
"unicode"
)
// namedColors maps color names to hex values per SEDMAT spec.
var namedColors = map[string]string{
"black": "#000000",
"white": "#FFFFFF",
"red": "#FF0000",
"green": "#00FF00",
"blue": "#0000FF",
"yellow": "#FFFF00",
"cyan": "#00FFFF",
"magenta": "#FF00FF",
"orange": "#FF8C00",
"purple": "#800080",
"pink": "#FF69B4",
"brown": "#8B4513",
"gray": "#808080",
"grey": "#808080",
"lightgray": "#D3D3D3",
"darkgray": "#404040",
"navy": "#000080",
"teal": "#008080",
}
// resolveColor returns hex from a color name or passes through hex values.
// If the input is a named color, returns its hex equivalent.
// If already hex (#RRGGBB), returns as-is.
// Otherwise returns the input unchanged.
func resolveColor(s string) string {
lower := strings.ToLower(s)
if hex, ok := namedColors[lower]; ok {
return hex
}
// Already hex or unknown — return as-is
return s
}
// headingMap maps SEDMAT heading values to Google Docs named styles.
var headingMap = map[string]string{
"t": "TITLE",
"s": "SUBTITLE",
"1": "HEADING_1",
"2": "HEADING_2",
"3": "HEADING_3",
"4": "HEADING_4",
"5": "HEADING_5",
"6": "HEADING_6",
"0": "NORMAL_TEXT",
}
// resolveHeading converts SEDMAT heading shorthand to Google Docs named style.
func resolveHeading(h string) string {
if mapped, ok := headingMap[h]; ok {
return mapped
}
// Check for numeric string
if len(h) == 1 && h[0] >= '1' && h[0] <= '6' {
return fmt.Sprintf("HEADING_%s", h)
}
return h
}
// alignMap maps SEDMAT alignment values to Google Docs alignment constants.
var alignMap = map[string]string{
"left": "START",
"center": "CENTER",
"right": "END",
"justify": "JUSTIFIED",
}
// resolveAlign converts SEDMAT alignment shorthand to Google Docs alignment.
func resolveAlign(a string) string {
if mapped, ok := alignMap[strings.ToLower(a)]; ok {
return mapped
}
return a
}
// breakMap maps SEDMAT break values to descriptions.
var breakMap = map[string]string{
"": "horizontal_rule",
"p": "page_break",
"c": "column_break",
"s": "section_break",
}
// resolveBreak converts SEDMAT break shorthand to a descriptive string.
func resolveBreak(b string) string {
if mapped, ok := breakMap[b]; ok {
return mapped
}
return b
}
// isHexColor returns true if s looks like a valid hex color (#RRGGBB or #RGB).
func isHexColor(s string) bool {
if !strings.HasPrefix(s, "#") {
return false
}
s = s[1:]
if len(s) != 3 && len(s) != 6 {
return false
}
for _, c := range s {
if !unicode.Is(unicode.ASCII_Hex_Digit, c) {
return false
}
}
return true
}
// normalizeHexColor converts #RGB to #RRGGBB format and uppercases.
func normalizeHexColor(s string) string {
if !strings.HasPrefix(s, "#") {
return s
}
hex := strings.ToUpper(s[1:])
if len(hex) == 3 {
// Expand #RGB to #RRGGBB
return fmt.Sprintf("#%c%c%c%c%c%c", hex[0], hex[0], hex[1], hex[1], hex[2], hex[2])
}
return "#" + hex
}

View File

@ -0,0 +1,509 @@
// Package cmd provides CLI commands for Google Docs operations.
package cmd
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"google.golang.org/api/docs/v1"
)
// buildColumnsRequest creates UpdateSectionStyleRequest to set column count.
// The range should span the section containing the match.
func buildColumnsRequest(be *braceExpr, sectionStart, sectionEnd int64) []*docs.Request {
if be == nil || be.Cols <= 0 {
return nil
}
// Build column properties array — one per column with equal width
// Google Docs handles equal distribution automatically when widths not specified
// Google Docs requires padding on each column property.
// Default 36pt (0.5in) gap between columns.
var colProps []*docs.SectionColumnProperties
for i := 0; i < be.Cols; i++ {
colProps = append(colProps, &docs.SectionColumnProperties{
PaddingEnd: &docs.Dimension{Magnitude: 36, Unit: "PT"},
})
}
return []*docs.Request{
{
UpdateSectionStyle: &docs.UpdateSectionStyleRequest{
Range: &docs.Range{
StartIndex: sectionStart,
EndIndex: sectionEnd,
},
SectionStyle: &docs.SectionStyle{
ColumnProperties: colProps,
ColumnSeparatorStyle: "NONE",
},
Fields: "columnProperties,columnSeparatorStyle",
},
},
}
}
// buildCheckboxRequests creates a checklist paragraph using BULLET_CHECKBOX preset.
// For {check=y}, the checked state cannot be toggled via API — Google Docs API
// creates unchecked checkboxes only. Users must manually check them.
func buildCheckboxRequests(be *braceExpr, start, end int64) []*docs.Request {
if be == nil || be.Check == nil {
return nil
}
// CreateParagraphBullets with BULLET_CHECKBOX preset creates checkbox lists.
// Note: The API does not support setting the checked state programmatically.
// All checkboxes are created unchecked; {check=y} is documented as a limitation.
return []*docs.Request{
{
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end + 1},
BulletPreset: "BULLET_CHECKBOX",
},
},
}
}
// buildTOCRequest creates requests to insert a Table of Contents.
// Google Docs API does NOT support InsertTableOfContents via batchUpdate.
// This is a documented API limitation — TOC must be inserted manually via the UI.
//
// TODO: When Google adds InsertTableOfContentsRequest to the API, implement it here.
// For now, this function returns nil and the limitation is documented.
func buildTOCRequest(be *braceExpr, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support
if be == nil || !be.HasTOC {
return nil
}
// Google Docs API limitation: No InsertTableOfContentsRequest exists.
// The API can read TOC elements but cannot create them programmatically.
// Return nil and document as unsupported.
return nil
}
// buildCommentRequest creates requests to add a comment/annotation to text.
// Google Docs batchUpdate API does NOT support creating comments.
// Comments must be created via the Drive Comments API (drive.comments.create).
//
// TODO: Implement via Drive API separately if needed.
// For now, this function returns nil and the limitation is documented.
func buildCommentRequest(be *braceExpr, _, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support
if be == nil || be.Comment == "" {
return nil
}
// Google Docs API limitation: Comments are not supported in batchUpdate.
// The Drive API (v3) supports comments via drive.comments.create,
// but that requires a separate API call outside of batchUpdate.
// Return nil and document as unsupported in this flow.
return nil
}
// buildBookmarkRequest creates a NamedRange (bookmark) at the matched text.
// Bookmarks can be linked via {u=#name} syntax.
func buildBookmarkRequest(be *braceExpr, start, end int64) []*docs.Request {
if be == nil || be.Bookmark == "" {
return nil
}
return []*docs.Request{
{
CreateNamedRange: &docs.CreateNamedRangeRequest{
Name: be.Bookmark,
Range: &docs.Range{
StartIndex: start,
EndIndex: end,
},
},
},
}
}
// buildPersonChipRequest creates an InsertPerson request for person smart chips.
// Syntax: chip://person/email@example.com
func buildPersonChipRequest(email string, index int64) []*docs.Request {
if email == "" {
return nil
}
return []*docs.Request{
{
InsertPerson: &docs.InsertPersonRequest{
Location: &docs.Location{Index: index},
PersonProperties: &docs.PersonProperties{
Email: email,
},
},
},
}
}
// ChipType represents the type of smart chip to insert.
type ChipType int
const (
ChipTypeUnknown ChipType = iota
ChipTypePerson
ChipTypeDate
ChipTypeFile
ChipTypePlace
ChipTypeDropdown
ChipTypeChart
ChipTypeBookmark
)
// ChipSpec holds parsed smart chip specification.
type ChipSpec struct {
Type ChipType
Value string // email for person, date string for date, etc.
Options []string // dropdown options
}
// parseChipURI parses a chip:// URI into a ChipSpec.
// Supported formats:
// - chip://person/email@example.com
// - chip://date/2026-03-15
// - chip://file/DOC_ID
// - chip://place/Orlando, FL
// - chip://dropdown/Draft|Review|Done
// - chip://bookmark/section-1
func parseChipURI(uri string) *ChipSpec {
if !strings.HasPrefix(uri, "chip://") {
return nil
}
// Remove prefix
path := uri[7:] // "chip://" is 7 chars
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
return nil
}
chipType := strings.ToLower(parts[0])
value := parts[1]
spec := &ChipSpec{Value: value}
switch chipType {
case "person":
spec.Type = ChipTypePerson
case "date":
spec.Type = ChipTypeDate
case strFile:
spec.Type = ChipTypeFile
case "place":
spec.Type = ChipTypePlace
case "dropdown":
spec.Type = ChipTypeDropdown
// Parse pipe-separated options
spec.Options = strings.Split(value, "|")
case "chart":
spec.Type = ChipTypeChart
case "bookmark":
spec.Type = ChipTypeBookmark
default:
return nil
}
return spec
}
// buildChipRequests creates requests for smart chip insertion based on chip:// URI.
// Supported chips:
// - person: Uses InsertPerson API (fully supported)
// - bookmark: Uses Link with BookmarkId (fully supported)
//
// Limited/unsupported chips (documented as API limitations):
// - date: No InsertDate request in API
// - file: RichLink is read-only
// - place: No InsertPlace request in API
// - dropdown: No InsertDropdown request in API
// - chart: Use InsertInlineSheetsChart (requires sheetId, separate handling)
func buildChipRequests(be *braceExpr, index int64) []*docs.Request {
if be == nil || be.URL == "" || !strings.HasPrefix(be.URL, "chip://") {
return nil
}
chip := parseChipURI(be.URL)
if chip == nil {
return nil
}
switch chip.Type {
case ChipTypePerson:
return buildPersonChipRequest(chip.Value, index)
case ChipTypeBookmark:
// Bookmark chip is handled via link with BookmarkId
// This is covered in buildBraceTextStyleRequests via {u=#name}
// We convert chip://bookmark/name to #name for consistency
return nil
case ChipTypeDate:
// Google Docs API limitation: No InsertDate request.
// Date chips cannot be created programmatically.
// TODO: When API support is added, implement here.
return nil
case ChipTypeFile:
// Google Docs API limitation: RichLink is read-only.
// File chips cannot be created via batchUpdate.
// Workaround: Insert a regular hyperlink to the Drive file.
return nil
case ChipTypePlace:
// Google Docs API limitation: No InsertPlace request.
// Place chips cannot be created programmatically.
return nil
case ChipTypeDropdown:
// Google Docs API limitation: No InsertDropdown request.
// Dropdown chips cannot be created programmatically.
return nil
case ChipTypeChart:
// InsertInlineSheetsChart exists but requires sheetId and chartId.
// Parse format: chip://chart/SHEET_ID/CHART_INDEX
// This is complex and handled separately in docs_sed_image.go
return nil
}
return nil
}
// hasBraceStructuralFeatures returns true if the braceExpr has any structural features
// that require special handling beyond text/paragraph formatting.
func hasBraceStructuralFeatures(be *braceExpr) bool {
if be == nil {
return false
}
return be.Cols > 0 ||
be.Check != nil ||
be.HasTOC ||
be.Comment != "" ||
be.Bookmark != "" ||
(be.URL != "" && strings.HasPrefix(be.URL, "chip://"))
}
// buildStructuralRequests builds all structural requests for a braceExpr.
// Returns:
// - columnReqs: requests that modify section style (must be applied to sections)
// - bulletReqs: checkbox/bullet requests (must be applied after text insertion)
// - anchorReqs: bookmark/named range requests (must be applied to text ranges)
// - chipReqs: smart chip requests (must be applied at indices)
func buildStructuralRequests(
be *braceExpr,
textStart, textEnd int64,
sectionStart, sectionEnd int64,
) (columnReqs, bulletReqs, anchorReqs, chipReqs []*docs.Request) {
if be == nil {
return nil, nil, nil, nil
}
// Columns (section-level)
if be.Cols > 0 {
columnReqs = buildColumnsRequest(be, sectionStart, sectionEnd)
}
// Checkboxes (paragraph-level bullets)
if be.Check != nil {
bulletReqs = buildCheckboxRequests(be, textStart, textEnd)
}
// Bookmarks (text-level anchors)
if be.Bookmark != "" {
anchorReqs = buildBookmarkRequest(be, textStart, textEnd)
}
// Smart chips
if be.URL != "" && strings.HasPrefix(be.URL, "chip://") {
chipReqs = buildChipRequests(be, textStart)
}
return columnReqs, bulletReqs, anchorReqs, chipReqs
}
// parseDate parses a date string into components.
// Supports: YYYY-MM-DD, YYYY/MM/DD, MM-DD-YYYY, MM/DD/YYYY
func parseDate(dateStr string) (year, month, day int, ok bool) {
// Try standard formats
formats := []string{
"2006-01-02",
"2006/01/02",
"01-02-2006",
"01/02/2006",
"January 2, 2006",
"Jan 2, 2006",
}
for _, f := range formats {
if t, err := time.Parse(f, dateStr); err == nil {
return t.Year(), int(t.Month()), t.Day(), true
}
}
return 0, 0, 0, false
}
// buildDateFallbackText creates a formatted date string for fallback display
// when native date chips are not available.
func buildDateFallbackText(dateStr string) string {
if year, month, day, ok := parseDate(dateStr); ok {
return fmt.Sprintf("%04d-%02d-%02d", year, month, day)
}
return dateStr
}
// resolveChipURL processes chip:// URLs and returns either:
// - The original URL for non-chip URLs
// - A fallback URL for chips that can be approximated with links
// - Empty string for chips that have no link fallback
func resolveChipURL(chipURL string) (resolvedURL string, fallbackText string) {
if !strings.HasPrefix(chipURL, "chip://") {
return chipURL, ""
}
chip := parseChipURI(chipURL)
if chip == nil {
return "", ""
}
switch chip.Type {
case ChipTypePerson:
// Person chips are handled natively via InsertPerson
return "", ""
case ChipTypeBookmark:
// Convert to bookmark link
return "#" + chip.Value, ""
case ChipTypeDate:
// No link fallback for dates
return "", buildDateFallbackText(chip.Value)
case ChipTypeFile:
// Convert to Drive file link
return "https://docs.google.com/document/d/" + chip.Value, ""
case ChipTypePlace:
// Convert to Google Maps link
return "https://maps.google.com/?q=" + url.QueryEscape(chip.Value), chip.Value
case ChipTypeDropdown:
// No link fallback, return options as text
return "", strings.Join(chip.Options, " / ")
case ChipTypeChart:
// Charts need separate handling via InsertInlineSheetsChart
return "", ""
}
return "", ""
}
// getCheckboxState returns a human-readable checkbox state description.
func getCheckboxState(be *braceExpr) string {
if be == nil || be.Check == nil {
return ""
}
if *be.Check {
return "checked"
}
return "unchecked"
}
// buildSectionRangeForMatch finds the section boundaries containing a match.
// In Google Docs, sections are delimited by SectionBreak elements.
// If the document has no section breaks, the entire body is one section.
func buildSectionRangeForMatch(doc *docs.Document, matchStart, matchEnd int64) (sectionStart, sectionEnd int64) {
if doc == nil || doc.Body == nil {
return 1, matchEnd + 1
}
// Find section boundaries by scanning for SectionBreak elements
sectionStart = 1 // Body starts at index 1
sectionEnd = matchEnd + 1
// Track the last section break we passed
for _, elem := range doc.Body.Content {
if elem.SectionBreak != nil {
// If we've passed this section break and it's before the match
if elem.EndIndex <= matchStart {
sectionStart = elem.EndIndex
}
// If this section break is after the match, it's the end boundary
if elem.StartIndex > matchEnd && sectionEnd == matchEnd+1 {
sectionEnd = elem.StartIndex
break
}
}
// Update sectionEnd to the last element's end index
if elem.EndIndex > sectionEnd {
sectionEnd = elem.EndIndex
}
}
// Ensure valid range
if sectionEnd <= sectionStart {
sectionEnd = sectionStart + 1
}
return sectionStart, sectionEnd
}
// stripChipPrefix removes the chip:// prefix and type from a URL
// and returns just the value portion. Used for fallback text display.
func stripChipPrefix(uri string) string {
if !strings.HasPrefix(uri, "chip://") {
return uri
}
path := uri[7:]
if idx := strings.Index(path, "/"); idx >= 0 {
return path[idx+1:]
}
return path
}
// isPersonChip returns true if the URL is a person chip.
func isPersonChip(uri string) bool {
return strings.HasPrefix(uri, "chip://person/")
}
// extractPersonEmail extracts the email from a person chip URL.
func extractPersonEmail(uri string) string {
if !isPersonChip(uri) {
return ""
}
return uri[14:] // len("chip://person/") = 14
}
// parseChartChip parses a chart chip URL.
// Format: chip://chart/SHEET_ID/CHART_INDEX
// Returns sheetId and chartIndex (0-based).
func parseChartChip(uri string) (sheetID string, chartIndex int, ok bool) {
if !strings.HasPrefix(uri, "chip://chart/") {
return "", 0, false
}
path := uri[13:] // len("chip://chart/") = 13
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
return "", 0, false
}
sheetID = parts[0]
idx, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, false
}
return sheetID, idx, true
}
// TODO: Implement full chart insertion in docs_sed_image.go using:
// InsertInlineSheetsChart (need to check if this request type exists)
// For now, chart chips are not supported via this path.

View File

@ -0,0 +1,222 @@
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
}
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 {
cmd := "appended"
if before {
cmd = "inserted"
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, 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 {
cmd := "appended"
if before {
cmd = "inserted"
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, 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)
}
cmd := "appended"
if before {
cmd = "inserted"
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, 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")
}

View File

@ -0,0 +1,198 @@
package cmd
import (
"context"
"fmt"
"strings"
"github.com/steipete/gogcli/internal/ui"
)
// runDryRun validates and classifies expressions without making API calls.
// It prints a summary of each expression's type, validity, and content.
// No authentication is required since no Google API calls are made.
func (c *DocsSedCmd) runDryRun(_ context.Context, u *ui.UI, exprs []sedExpr) error {
for i, expr := range exprs {
kind := classifyExpression(expr)
valid := "ok"
if expr.pattern != "" && expr.pattern != "^" && expr.pattern != "$" && expr.pattern != "^$" {
if _, err := expr.compilePattern(); err != nil {
valid = fmt.Sprintf("ERROR: %s", err)
}
}
flag := ""
if expr.global {
flag = "g"
}
prefix := "s"
if expr.command != 0 {
prefix = string(expr.command)
}
nthStr := ""
if expr.nthMatch > 0 {
nthStr = fmt.Sprintf("%d", expr.nthMatch)
}
// Include brace flags in output if present
braceInfo := ""
if expr.brace != nil {
braceInfo = formatBraceFlags(expr.brace)
}
if braceInfo != "" {
u.Out().Printf("%d\t%s\t%s\t%s/%s/%s/%s%s\t%s", i+1, kind, valid, prefix, expr.pattern, truncateSed(expr.replacement, 40), flag, nthStr, braceInfo)
} else {
u.Out().Printf("%d\t%s\t%s\t%s/%s/%s/%s%s", i+1, kind, valid, prefix, expr.pattern, truncateSed(expr.replacement, 40), flag, nthStr)
}
}
u.Out().Printf("---")
u.Out().Printf("dry-run: %d expressions parsed, no changes made", len(exprs))
return nil
}
// classifyExpression determines the type of a sed expression for dry-run display.
func classifyExpression(expr sedExpr) string {
switch expr.command {
case 'd':
return "delete"
case 'a':
return "append-after"
case 'i':
return "insert-before"
case 'y':
return "transliterate"
}
if expr.cellRef != nil {
kind := fmt.Sprintf("cell |%d|[%d,%d]", expr.cellRef.tableIndex, expr.cellRef.row, expr.cellRef.col)
if expr.cellRef.row == 0 || expr.cellRef.col == 0 {
kind += " (wildcard)"
}
return kind
}
if expr.tableRef != 0 {
if expr.replacement == "" {
return fmt.Sprintf("delete table %d", expr.tableRef)
}
return fmt.Sprintf("table %d op", expr.tableRef)
}
if parseTableCreate(expr.replacement) != nil || parseTableFromPipes(expr.replacement) != nil {
return "create table"
}
if parseImageRefPattern(expr.pattern) != nil {
return "image"
}
if expr.pattern == "^" || expr.pattern == "$" || expr.pattern == "^$" {
return "positional"
}
// Check for brace formatting
if expr.brace != nil && braceExprHasAnyFormat(expr.brace) {
return "brace"
}
if canUseNativeReplace(expr.replacement) && expr.global && expr.brace == nil {
return "native"
}
return "manual"
}
// formatBraceFlags returns a compact string representation of brace flags for display.
func formatBraceFlags(be *braceExpr) string {
if be == nil {
return ""
}
var parts []string
// Reset
if be.Reset {
parts = append(parts, "0")
}
// Boolean flags
if be.Bold != nil {
if *be.Bold {
parts = append(parts, "b")
} else {
parts = append(parts, "!b")
}
}
if be.Italic != nil {
if *be.Italic {
parts = append(parts, "i")
} else {
parts = append(parts, "!i")
}
}
if be.Underline != nil {
if *be.Underline {
parts = append(parts, "_")
} else {
parts = append(parts, "!_")
}
}
if be.Strike != nil {
if *be.Strike {
parts = append(parts, "-")
} else {
parts = append(parts, "!-")
}
}
if be.Code != nil && *be.Code {
parts = append(parts, "#")
}
if be.Sup != nil && *be.Sup {
parts = append(parts, "^")
}
if be.Sub != nil && *be.Sub {
parts = append(parts, ",")
}
if be.SmallCaps != nil && *be.SmallCaps {
parts = append(parts, "w")
}
// Value flags
if be.Color != "" {
parts = append(parts, "c="+be.Color)
}
if be.Bg != "" {
parts = append(parts, "z="+be.Bg)
}
if be.Font != "" {
parts = append(parts, "f="+be.Font)
}
if be.Size > 0 {
parts = append(parts, fmt.Sprintf("s=%.0f", be.Size))
}
if be.URL != "" {
parts = append(parts, "u="+truncateSed(be.URL, 20))
}
if be.Heading != "" {
parts = append(parts, "h="+be.Heading)
}
if be.Align != "" {
parts = append(parts, "a="+be.Align)
}
if be.HasBreak {
if be.Break == "" {
parts = append(parts, "+")
} else {
parts = append(parts, "+="+be.Break)
}
}
if len(parts) == 0 {
return ""
}
return "{" + strings.Join(parts, " ") + "}"
}
// truncateSed shortens a string to max characters, appending "..." if truncated.
func truncateSed(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}

View File

@ -0,0 +1,470 @@
package cmd
import (
"context"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
// compilePattern compiles the sedExpr's pattern into a regexp.
func (e *sedExpr) compilePattern() (*regexp.Regexp, error) {
return regexp.Compile(e.pattern)
}
// batchUpdate executes a Documents.BatchUpdate with retry-on-quota.
// Returns the response (may be nil on success with no replies).
func batchUpdate(ctx context.Context, docsSvc *docs.Service, docID string, reqs []*docs.Request) (*docs.BatchUpdateDocumentResponse, error) {
if len(reqs) == 0 {
return nil, nil
}
var resp *docs.BatchUpdateDocumentResponse
err := retryOnQuota(ctx, func() error {
var e error
resp, e = docsSvc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: reqs,
}).Context(ctx).Do()
return e
})
return resp, err
}
// getDoc fetches a document with retry-on-quota.
func getDoc(ctx context.Context, docsSvc *docs.Service, docID string) (*docs.Document, error) {
var doc *docs.Document
err := retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(docID).Context(ctx).Do()
return e
})
return doc, err
}
// codeBackgroundGrey is the RGB value for inline code background shading.
const codeBackgroundGrey = 0.95
// borderGrey is the grey intensity for borders (blockquotes, horizontal rules).
const borderGrey = 0.8
// indentPointsPerLevel is the number of points per indent level in Google Docs.
const indentPointsPerLevel = 36.0
// hrulePaddingPt is the padding in points below a horizontal rule border.
const hrulePaddingPt = 6.0
// blockquoteBorderWidthPt is the border width in points for blockquotes.
const blockquoteBorderWidthPt = 3.0
// blockquoteIndentPt is the left indent in points for blockquotes.
const blockquoteIndentPt = 36.0
// blockquotePaddingPt is the left padding in points for blockquotes.
const blockquotePaddingPt = 12.0
// bulletPresetDisc is the default unordered bullet preset.
const bulletPresetDisc = "BULLET_DISC_CIRCLE_SQUARE"
// Table cell operation constants.
const (
opAppend = "append"
opInsert = "insert"
opDelete = "delete"
)
// Table merge/split operation constants.
const (
mergeOp = "merge"
unmergeOp = "unmerge"
splitOp = "split"
)
// buildImageSizeSpec returns a Size for the image spec, or nil if no dimensions set.
func buildImageSizeSpec(spec *ImageSpec) *docs.Size {
if spec.Width == 0 && spec.Height == 0 {
return nil
}
size := &docs.Size{}
if spec.Width > 0 {
size.Width = &docs.Dimension{Magnitude: float64(spec.Width), Unit: "PT"}
}
if spec.Height > 0 {
size.Height = &docs.Dimension{Magnitude: float64(spec.Height), Unit: "PT"}
}
return size
}
// buildCellReplaceRequests builds delete+insert+format requests for replacing table cell content.
// If deleteEnd > startIdx, deletes old content. Inserts plainText at startIdx and applies formats.
func buildCellReplaceRequests(startIdx, deleteEnd int64, plainText string, formats []string) []*docs.Request {
var requests []*docs.Request
if startIdx < deleteEnd {
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{StartIndex: startIdx, EndIndex: deleteEnd},
},
})
}
if plainText != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: startIdx},
Text: plainText,
},
})
if len(formats) > 0 {
end := startIdx + int64(len(plainText))
requests = append(requests, buildTextStyleRequests(formats, startIdx, end)...)
}
}
return requests
}
// sedOutputKV is an ordered key-value pair for sedOutputOK.
type sedOutputKV struct {
Key string
Value any
}
// sedOutputOK writes the standard sed output (status=ok, docId, plus extra key-value pairs).
// Keys are output in the order provided.
func sedOutputOK(ctx context.Context, u *ui.UI, id string, extra ...sedOutputKV) error {
result := map[string]any{"status": "ok", "docId": id}
for _, kv := range extra {
result[kv.Key] = kv.Value
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, result)
}
u.Out().Printf("status\tok")
u.Out().Printf("docId\t%s", id)
for _, kv := range extra {
u.Out().Printf("%s\t%v", kv.Key, kv.Value)
}
return nil
}
// buildTextStyleRequests creates UpdateTextStyle requests from format strings.
// Handles: bold, italic, strikethrough, code, underline, link:URL, smallcaps,
// font:NAME, size:N, color:#HEX, bg:#HEX
// Returns empty slice if no text-level formats found.
func buildTextStyleRequests(formats []string, start, end int64) []*docs.Request {
textStyle := &docs.TextStyle{}
var textFields []string
for _, f := range formats {
switch f {
case "bold":
textStyle.Bold = true
textFields = append(textFields, "bold")
case "italic":
textStyle.Italic = true
textFields = append(textFields, "italic")
case "strikethrough":
textStyle.Strikethrough = true
textFields = append(textFields, "strikethrough")
case inlineTypeCode:
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: "Courier New"}
textStyle.BackgroundColor = greyColor(codeBackgroundGrey)
textFields = append(textFields, "weightedFontFamily", "backgroundColor")
case "underline":
textStyle.Underline = true
textFields = append(textFields, "underline")
case "superscript":
textStyle.BaselineOffset = "SUPERSCRIPT"
textFields = append(textFields, "baselineOffset")
case "subscript":
textStyle.BaselineOffset = "SUBSCRIPT"
textFields = append(textFields, "baselineOffset")
case "smallcaps":
textStyle.SmallCaps = true
textFields = append(textFields, "smallCaps")
default:
switch {
case strings.HasPrefix(f, "link:"):
linkURL := f[5:]
if strings.HasPrefix(linkURL, "#") {
textStyle.Link = &docs.Link{BookmarkId: linkURL[1:]}
} else {
textStyle.Link = &docs.Link{Url: linkURL}
}
textFields = append(textFields, "link")
case strings.HasPrefix(f, "font:"):
fontName := f[5:]
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: fontName}
textFields = append(textFields, "weightedFontFamily")
case strings.HasPrefix(f, "size:"):
sizeStr := f[5:]
if size, err := strconv.ParseFloat(sizeStr, 64); err == nil && size > 0 {
textStyle.FontSize = &docs.Dimension{Magnitude: size, Unit: "PT"}
textFields = append(textFields, "fontSize")
}
case strings.HasPrefix(f, "color:"):
colorVal := f[6:]
if r, g, b, ok := parseHexColor(colorVal); ok {
textStyle.ForegroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
textFields = append(textFields, "foregroundColor")
}
case strings.HasPrefix(f, "bg:"):
bgVal := f[3:]
if r, g, b, ok := parseHexColor(bgVal); ok {
textStyle.BackgroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
textFields = append(textFields, "backgroundColor")
}
}
}
}
if len(textFields) == 0 {
return nil
}
return []*docs.Request{
{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
TextStyle: textStyle,
Fields: strings.Join(textFields, ","),
},
},
}
}
// buildParagraphStyleRequests creates UpdateParagraphStyle and/or CreateParagraphBullets
// requests from format strings. The end parameter should already include the +1 for
// paragraph newline (caller decides).
// Handles: heading1-6, bullet, checkbox, numbered.
// Returns empty slice if no paragraph-level formats found.
func buildParagraphStyleRequests(formats []string, start, end int64) []*docs.Request {
var headingLevel int
var bulletPreset string
var isBlockquote bool
for _, f := range formats {
if strings.HasPrefix(f, "heading") && len(f) == 8 {
level := int(f[7] - '0')
if level >= 1 && level <= 6 {
headingLevel = level
}
}
switch f {
case "bullet":
bulletPreset = bulletPresetDisc
case "checkbox":
bulletPreset = "BULLET_CHECKBOX"
case "numbered":
bulletPreset = "NUMBERED_DECIMAL_NESTED"
case "blockquote":
isBlockquote = true
}
}
var requests []*docs.Request
if headingLevel > 0 {
namedStyle := fmt.Sprintf("HEADING_%d", headingLevel)
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
ParagraphStyle: &docs.ParagraphStyle{
NamedStyleType: namedStyle,
},
Fields: "namedStyleType",
},
})
}
if bulletPreset != "" {
requests = append(requests, &docs.Request{
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
BulletPreset: bulletPreset,
},
})
// Nesting is handled by prepending \t characters before the text content.
// When CreateParagraphBullets is applied, Google Docs converts leading
// tabs into nesting levels automatically. The tabs must already be in
// the text BEFORE the bullet request. See buildNestedListText().
}
if isBlockquote {
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
ParagraphStyle: &docs.ParagraphStyle{
IndentStart: &docs.Dimension{Magnitude: blockquoteIndentPt, Unit: "PT"},
BorderLeft: &docs.ParagraphBorder{
Color: greyColor(borderGrey),
Width: &docs.Dimension{Magnitude: blockquoteBorderWidthPt, Unit: "PT"},
DashStyle: "SOLID",
Padding: &docs.Dimension{Magnitude: blockquotePaddingPt, Unit: "PT"},
},
},
Fields: "indentStart,borderLeft",
},
})
}
return requests
}
// buildHruleBorderRequest returns an UpdateParagraphStyle request that styles a paragraph
// as a horizontal rule (bottom border only).
func buildHruleBorderRequest(start, end int64) *docs.Request {
return &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
ParagraphStyle: &docs.ParagraphStyle{
BorderBottom: &docs.ParagraphBorder{
Color: greyColor(borderGrey),
Width: &docs.Dimension{Magnitude: 1, Unit: "PT"},
DashStyle: "SOLID",
Padding: &docs.Dimension{Magnitude: hrulePaddingPt, Unit: "PT"},
},
},
Fields: "borderBottom",
},
}
}
// greyColor returns an OptionalColor with the given greyscale intensity (0.0=black, 1.0=white).
func greyColor(intensity float64) *docs.OptionalColor {
return &docs.OptionalColor{Color: &docs.Color{RgbColor: &docs.RgbColor{Red: intensity, Green: intensity, Blue: intensity}}}
}
// containsFormat returns true if the format slice contains the given format string.
func containsFormat(formats []string, f string) bool {
for _, v := range formats {
if v == f {
return true
}
}
return false
}
// inlineScriptRe matches inline superscript/subscript markers:
// - {super=text} and {sub=text} (preferred)
// - ^{text} and ~{text} (deprecated, still supported)
// (Legacy buildInlineScriptRequests and buildAttrRequests removed — use brace syntax instead)
// parseHexColor converts #RRGGBB or #RGB to normalized 0.0-1.0 RGB floats.
func parseHexColor(hex string) (r, g, b float64, ok bool) {
hex = strings.TrimPrefix(hex, "#")
// Expand #RGB shorthand to #RRGGBB
if len(hex) == 3 {
hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
}
if len(hex) != 6 {
return 0, 0, 0, false
}
// Parse all 6 hex digits as a single uint to avoid 3 separate ParseUint calls.
rgb, err := strconv.ParseUint(hex, 16, 24)
if err != nil {
return 0, 0, 0, false
}
return float64((rgb>>16)&0xFF) / 255.0, float64((rgb>>8)&0xFF) / 255.0, float64(rgb&0xFF) / 255.0, true
}
// Markdown escape placeholders — package-level to avoid per-call allocation.
const (
escAsterisk = "\x00ESC_ASTERISK\x00"
escHash = "\x00ESC_HASH\x00"
escTilde = "\x00ESC_TILDE\x00"
escBacktick = "\x00ESC_BACKTICK\x00"
escDash = "\x00ESC_DASH\x00"
escPlus = "\x00ESC_PLUS\x00"
escBackslash = "\x00ESC_BACKSLASH\x00"
)
// Package-level replacers for markdown escape/unescape — allocated once.
var (
mdEscaper = strings.NewReplacer(
"\\\\", escBackslash, "\\*", escAsterisk, "\\#", escHash,
"\\~", escTilde, "\\`", escBacktick, "\\-", escDash,
"\\+", escPlus, "\\n", "\n",
)
mdUnescaper = strings.NewReplacer(
escAsterisk, "*", escHash, "#", escTilde, "~",
escBacktick, "`", escDash, "-", escPlus, "+",
escBackslash, "\\",
)
)
// escapeMarkdown replaces escaped markdown characters with placeholders.
func escapeMarkdown(s string) string { return mdEscaper.Replace(s) }
// unescapeMarkdown restores escaped markdown characters from placeholders.
func unescapeMarkdown(s string) string { return mdUnescaper.Replace(s) }
// nativeBlockMarkers are markdown format markers that prevent native API replacement.
// Package-level to avoid per-call allocation.
var nativeBlockMarkers = []string{
"**", "*", "~~", "`",
"# ", "## ", "### ", "#### ", "##### ", "###### ",
"- ", "+ ",
"> ",
"[^",
}
// canUseNativeReplace returns true if the replacement string contains no markdown
// formatting or brace expressions that require manual (per-run) application,
// allowing the faster native Google Docs FindReplace API to be used instead.
func canUseNativeReplace(replacement string) bool {
// Check for SEDMAT brace formatting ({b}, {c=red}, etc.)
if hasBraceFormatting(replacement) {
return false
}
// Check for image syntax (both ![alt](url) and !(url) shorthand)
if strings.HasPrefix(replacement, "![") {
return false
}
if strings.HasPrefix(replacement, "!(") && strings.HasSuffix(replacement, ")") {
inner := replacement[2 : len(replacement)-1]
if strings.HasPrefix(inner, "http://") || strings.HasPrefix(inner, "https://") {
return false
}
}
for _, marker := range nativeBlockMarkers {
if strings.Contains(replacement, marker) {
return false
}
}
// Horizontal rule
trimmedRepl := strings.TrimSpace(replacement)
if trimmedRepl == "---" || trimmedRepl == "***" || trimmedRepl == "___" {
return false
}
// Numbered list pattern
if len(replacement) >= 3 && replacement[0] >= '0' && replacement[0] <= '9' &&
replacement[1] == '.' && replacement[2] == ' ' {
return false
}
// Escape sequences
if strings.Contains(replacement, "\\n") {
return false
}
// Backreferences ($1, ${1}, etc.)
for i := 0; i < len(replacement)-1; i++ {
if replacement[i] == '$' {
next := replacement[i+1]
if (next >= '1' && next <= '9') || next == '{' {
return false
}
}
}
// Link syntax [text](url)
if strings.Contains(replacement, "](") {
return false
}
return true
}

View File

@ -0,0 +1,424 @@
package cmd
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
func (c *DocsSedCmd) runImageReplace(ctx context.Context, u *ui.UI, account, docID string, ref *ImageRefPattern, replacement string, global bool) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
// Get document to find images
var doc *docs.Document
err = retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(docID).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("get document: %w", err)
}
// Find all images in document
allImages := findDocImages(doc)
if len(allImages) == 0 {
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "no images found in document"})
}
// Match images against pattern
matched := matchImages(allImages, ref)
if len(matched) == 0 {
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "no images matched pattern"})
}
// If not global, only process first match
if !global && len(matched) > 1 {
matched = matched[:1]
}
// Parse replacement - could be new image, text, or empty (delete)
var requests []*docs.Request
isDelete := replacement == ""
newImage := parseImageSyntax(replacement)
if newImage == nil && strings.HasPrefix(replacement, "!(") && strings.HasSuffix(replacement, ")") {
// Check for !(url) shorthand
inner := replacement[2 : len(replacement)-1]
if strings.HasPrefix(inner, "http://") || strings.HasPrefix(inner, "https://") {
newImage = &ImageSpec{URL: inner}
}
}
// Build requests for each matched image
for _, img := range matched {
switch {
case isDelete:
// Delete the image
if img.IsPositioned {
requests = append(requests, &docs.Request{
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
ObjectId: img.ObjectID,
},
})
} else {
// For inline objects, delete the content range
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: img.Index,
EndIndex: img.Index + 1,
},
},
})
}
case newImage != nil:
// Replace with new image
if !img.IsPositioned {
// Use ReplaceImage for inline images
replaceReq := &docs.ReplaceImageRequest{
ImageObjectId: img.ObjectID,
Uri: newImage.URL,
}
requests = append(requests, &docs.Request{
ReplaceImage: replaceReq,
})
} else {
// For positioned objects, delete and insert new
requests = append(requests, &docs.Request{
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
ObjectId: img.ObjectID,
},
})
// Note: Can't easily insert positioned object, so this is a limitation
}
default:
// Replace with text - delete image, insert text
if img.IsPositioned {
requests = append(requests, &docs.Request{
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
ObjectId: img.ObjectID,
},
})
} else {
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: img.Index,
EndIndex: img.Index + 1,
},
},
})
if replacement != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: img.Index},
Text: replacement,
},
})
}
}
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0})
}
// Execute batch update
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("update document: %w", err)
}
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", len(matched)})
}
// ImageSpec holds the URL and optional dimensions for an inline image insertion.
type ImageSpec struct {
URL string
Alt string
Caption string // from title in ![alt](url "title")
Width int // in pixels, 0 if not specified
Height int // in pixels, 0 if not specified
}
// ImageRefPattern holds a parsed image reference pattern (for finding existing images)
type ImageRefPattern struct {
ByPosition bool // true if matching by position (!(n))
Position int // 1-based position, negative for from-end, 0 for all (*)
AllImages bool // true if matching all images (!(*) )
ByAlt bool // true if matching by alt text regex (![regex])
AltRegex *regexp.Regexp // compiled regex for alt text matching
}
// DocImage represents an image found in the document
type DocImage struct {
ObjectID string // inline object ID or positioned object ID
Index int64 // position in document
Alt string // alt text if available
IsPositioned bool // true if floating/positioned, false if inline
}
// parseImageRefPattern parses image reference patterns for finding existing images
// Patterns: !(1), !(-1), !(*), ![regex], ![](1), ![](-1), ![](*)
func parseImageRefPattern(pattern string) *ImageRefPattern {
// !(n) or !(*) - positional reference
if strings.HasPrefix(pattern, "!(") && strings.HasSuffix(pattern, ")") {
inner := pattern[2 : len(pattern)-1]
if inner == "*" {
return &ImageRefPattern{ByPosition: true, AllImages: true}
}
if n, err := strconv.Atoi(inner); err == nil {
return &ImageRefPattern{ByPosition: true, Position: n}
}
// Could be a URL, not a reference
if strings.HasPrefix(inner, "http://") || strings.HasPrefix(inner, "https://") {
return nil
}
return nil
}
// ![](n) or ![](*) - positional reference with empty alt
if strings.HasPrefix(pattern, "![](") && strings.HasSuffix(pattern, ")") {
inner := pattern[4 : len(pattern)-1]
if inner == "*" {
return &ImageRefPattern{ByPosition: true, AllImages: true}
}
if n, err := strconv.Atoi(inner); err == nil {
return &ImageRefPattern{ByPosition: true, Position: n}
}
return nil
}
// ![regex] - alt text regex match (no URL part)
if strings.HasPrefix(pattern, "![") && strings.HasSuffix(pattern, "]") && !strings.Contains(pattern, "](") {
regexStr := pattern[2 : len(pattern)-1]
if regexStr == "" {
return nil
}
// Compile as regex, anchor if it looks like exact match
re, err := regexp.Compile(regexStr)
if err != nil {
return nil
}
return &ImageRefPattern{ByAlt: true, AltRegex: re}
}
return nil
}
// findDocImages walks a document and returns all images with their metadata
func findDocImages(doc *docs.Document) []DocImage {
var images []DocImage
if doc.InlineObjects != nil {
// Build a map of inline object IDs to their properties
inlineProps := make(map[string]*docs.InlineObjectProperties)
for id, obj := range doc.InlineObjects {
if obj.InlineObjectProperties != nil {
inlineProps[id] = obj.InlineObjectProperties
}
}
// Walk document to find inline object elements and their positions
var walkContent func(content []*docs.StructuralElement)
walkContent = func(content []*docs.StructuralElement) {
for _, elem := range content {
if elem.Paragraph != nil {
for _, pe := range elem.Paragraph.Elements {
if pe.InlineObjectElement != nil {
objID := pe.InlineObjectElement.InlineObjectId
alt := ""
if props, ok := inlineProps[objID]; ok && props.EmbeddedObject != nil {
alt = props.EmbeddedObject.Title // or Description
if alt == "" {
alt = props.EmbeddedObject.Description
}
}
images = append(images, DocImage{
ObjectID: objID,
Index: pe.StartIndex,
Alt: alt,
IsPositioned: false,
})
}
}
}
if elem.Table != nil {
for _, row := range elem.Table.TableRows {
for _, cell := range row.TableCells {
walkContent(cell.Content)
}
}
}
}
}
if doc.Body != nil {
walkContent(doc.Body.Content)
}
}
// Also check positioned objects
if doc.PositionedObjects != nil {
for id, obj := range doc.PositionedObjects {
alt := ""
if obj.PositionedObjectProperties != nil && obj.PositionedObjectProperties.EmbeddedObject != nil {
alt = obj.PositionedObjectProperties.EmbeddedObject.Title
if alt == "" {
alt = obj.PositionedObjectProperties.EmbeddedObject.Description
}
}
images = append(images, DocImage{
ObjectID: id,
Index: 0, // positioned objects don't have a fixed index
Alt: alt,
IsPositioned: true,
})
}
}
return images
}
// matchImages returns images that match the reference pattern
func matchImages(images []DocImage, ref *ImageRefPattern) []DocImage {
if ref.AllImages {
return images
}
if ref.ByPosition {
pos := ref.Position
if pos > 0 && pos <= len(images) {
return []DocImage{images[pos-1]}
}
if pos < 0 && -pos <= len(images) {
return []DocImage{images[len(images)+pos]}
}
return nil
}
if ref.ByAlt && ref.AltRegex != nil {
var matched []DocImage
for _, img := range images {
if ref.AltRegex.MatchString(img.Alt) {
matched = append(matched, img)
}
}
return matched
}
return nil
}
// parseImageSyntax parses markdown image syntax: ![alt](url "title"){width=X height=Y}
// Returns nil if the text is not an image
func parseImageSyntax(text string) *ImageSpec {
// Must start with ![
if !strings.HasPrefix(text, "![") {
return nil
}
// Find the closing ] for alt text
altEnd := strings.Index(text, "](")
if altEnd == -1 {
return nil
}
alt := text[2:altEnd]
// Find the URL - starts after ]( and ends at ) or " or {
rest := text[altEnd+2:]
// Find where URL ends
urlEnd := -1
for i, c := range rest {
if c == '"' || c == ')' || c == '{' {
urlEnd = i
break
}
}
if urlEnd == -1 {
// URL goes to end, look for closing )
if strings.HasSuffix(rest, ")") {
urlEnd = len(rest) - 1
} else {
return nil
}
}
url := strings.TrimSpace(rest[:urlEnd])
rest = rest[urlEnd:]
spec := &ImageSpec{
URL: url,
Alt: alt,
}
// Parse optional title in quotes: "title")
if strings.HasPrefix(rest, " \"") || strings.HasPrefix(rest, "\"") {
rest = strings.TrimPrefix(rest, " ")
if strings.HasPrefix(rest, "\"") {
titleEnd := strings.Index(rest[1:], "\"")
if titleEnd != -1 {
spec.Caption = rest[1 : titleEnd+1]
rest = rest[titleEnd+2:]
}
}
}
// Skip closing paren if present
rest = strings.TrimPrefix(rest, ")")
// Parse optional Pandoc-style attributes: {width=X height=Y}
if strings.HasPrefix(rest, "{") {
attrEnd := strings.Index(rest, "}")
if attrEnd != -1 {
attrs := rest[1:attrEnd]
// Parse width=N and height=N
for _, part := range strings.Fields(attrs) {
switch {
case strings.HasPrefix(part, "width="):
val := strings.TrimPrefix(part, "width=")
val = strings.TrimSuffix(val, "px")
val = strings.TrimSuffix(val, "%") // percentage values treated as absolute px for now
if n, err := strconv.Atoi(val); err == nil {
spec.Width = n
}
case strings.HasPrefix(part, "height="):
val := strings.TrimPrefix(part, "height=")
val = strings.TrimSuffix(val, "px")
val = strings.TrimSuffix(val, "%")
if n, err := strconv.Atoi(val); err == nil {
spec.Height = n
}
case strings.HasPrefix(part, "w="):
val := strings.TrimPrefix(part, "w=")
if n, err := strconv.Atoi(val); err == nil {
spec.Width = n
}
case strings.HasPrefix(part, "h="):
val := strings.TrimPrefix(part, "h=")
if n, err := strconv.Atoi(val); err == nil {
spec.Height = n
}
}
}
}
}
return spec
}

View File

@ -0,0 +1,114 @@
package cmd
import (
"context"
"fmt"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
func (c *DocsSedCmd) doPositionalInsert(ctx context.Context, docsSvc *docs.Service, u *ui.UI, id string, idx int64, replacement string) error {
// Check for image syntax first
imgSpec := parseImageSyntax(replacement)
// Check for table creation (explicit |RxC| or pipe-table syntax)
tableSpec := parseTableCreate(replacement)
if tableSpec == nil {
tableSpec = parseTableFromPipes(replacement)
}
// Parse markdown formatting
plainText, formats := parseMarkdownReplacement(replacement)
var requests []*docs.Request
switch {
case tableSpec != nil:
// Insert a table at the position
requests = append(requests, &docs.Request{
InsertTable: &docs.InsertTableRequest{
Location: &docs.Location{Index: idx},
Rows: int64(tableSpec.rows),
Columns: int64(tableSpec.cols),
},
})
case imgSpec != nil:
// Insert an image
imgReq := &docs.InsertInlineImageRequest{
Uri: imgSpec.URL,
Location: &docs.Location{Index: idx},
ObjectSize: buildImageSizeSpec(imgSpec),
}
requests = append(requests, &docs.Request{InsertInlineImage: imgReq})
default:
// Insert plain text
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: idx},
Text: plainText,
},
})
// Apply text formatting if any
if len(formats) > 0 {
end := idx + int64(len(plainText))
requests = append(requests, buildTextStyleRequests(formats, idx, end)...)
}
}
// After inserting text, reset paragraph styles and apply heading/bullet if requested
if tableSpec == nil && imgSpec == nil && plainText != "" {
end := idx + int64(len(plainText))
// Reset paragraph to NORMAL_TEXT (removes inherited heading styles)
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: idx, EndIndex: end},
ParagraphStyle: &docs.ParagraphStyle{
NamedStyleType: "NORMAL_TEXT",
},
Fields: "namedStyleType",
},
})
// Remove inherited bullets
requests = append(requests, &docs.Request{
DeleteParagraphBullets: &docs.DeleteParagraphBulletsRequest{
Range: &docs.Range{StartIndex: idx, EndIndex: end},
},
})
// Apply heading/bullet formats from markdown
requests = append(requests, buildParagraphStyleRequests(formats, idx, end)...)
}
err := retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (positional insert): %w", err)
}
// Fill pipe-table cells if content was provided
if tableSpec != nil && len(tableSpec.cells) > 0 {
if err := c.fillTableCells(ctx, docsSvc, id, idx, tableSpec); err != nil {
return fmt.Errorf("fill table cells: %w", err)
}
}
label := fmt.Sprintf("%d chars", len(plainText))
if tableSpec != nil {
label = fmt.Sprintf("%dx%d table", tableSpec.rows, tableSpec.cols)
if len(tableSpec.cells) > 0 {
label += " (filled)"
}
} else if imgSpec != nil {
label = "image"
}
return sedOutputOK(ctx, u, id, sedOutputKV{"inserted", label})
}

View File

@ -0,0 +1,416 @@
package cmd
import (
"context"
"fmt"
"regexp"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
func (c *DocsSedCmd) runManual(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
count, bulletReqs, err := c.runManualInner(ctx, docsSvc, id, expr)
if err != nil {
return fmt.Errorf("manual replace: %w", err)
}
// Apply deferred bullet requests via re-fetch to get current positions
if len(bulletReqs) > 0 {
if err := c.applyDeferredBullets(ctx, docsSvc, id); err != nil {
return fmt.Errorf("apply bullets: %w", err)
}
}
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", count})
}
// runManualInner is like runManual but reuses an existing docsSvc and returns count
// plus any deferred bullet requests. Bullet requests are returned separately so that
// the caller can merge consecutive same-preset bullets into a single request —
// required for Google Docs to interpret leading \t as nesting levels.
// sedMatch represents a single regex match found in the document with its replacement info.
type sedMatch struct {
start, end int64
oldText string
newText string
formats []string
image *ImageSpec
braceExpr *braceExpr // SEDMAT v3.5 brace expression
braceSpans []*braceSpan // Inline scoping spans
}
// findDocMatches walks the document content, finds all regex matches, and returns them.
// It also handles nth-match filtering and global vs single-match logic.
func findDocMatches(doc *docs.Document, re *regexp.Regexp, expr sedExpr) []sedMatch {
var matches []sedMatch
var walkContent func(content []*docs.StructuralElement)
walkContent = func(content []*docs.StructuralElement) {
for _, elem := range content {
if elem.Paragraph != nil {
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun == nil || pe.TextRun.Content == "" {
continue
}
text := pe.TextRun.Content
baseIdx := pe.StartIndex
limit := -1
if !expr.global && expr.nthMatch <= 0 {
limit = 1
}
results := re.FindAllStringSubmatchIndex(text, limit)
for _, loc := range results {
oldText := text[loc[0]:loc[1]]
expanded := re.ReplaceAllString(oldText, expr.replacement)
matches = append(matches, classifyMatch(baseIdx, loc, oldText, expanded, expr))
}
}
}
if elem.Table != nil {
for _, row := range elem.Table.TableRows {
for _, cell := range row.TableCells {
walkContent(cell.Content)
}
}
}
}
}
if doc.Body != nil {
walkContent(doc.Body.Content)
}
// If nth-match is set, keep only the Nth occurrence across the whole document
if expr.nthMatch > 0 {
if len(matches) >= expr.nthMatch {
return matches[expr.nthMatch-1 : expr.nthMatch]
}
return nil
}
return matches
}
// classifyMatch creates a sedMatch from a regex match, determining if it's an image,
// brace expression, or plain text replacement.
func classifyMatch(baseIdx int64, loc []int, oldText, expanded string, expr sedExpr) sedMatch {
// Fast path: only attempt image parsing if replacement starts with ![
var imgSpec *ImageSpec
if strings.HasPrefix(expanded, "![") {
imgSpec = parseImageSyntax(expanded)
}
switch {
case imgSpec != nil:
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, image: imgSpec}
case expr.brace != nil && expr.brace.ImgRef != "":
// Brace image: {img=url x=W y=H}
spec := &ImageSpec{URL: expr.brace.ImgRef}
if expr.brace.Width > 0 {
spec.Width = expr.brace.Width
}
if expr.brace.Height > 0 {
spec.Height = expr.brace.Height
}
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, image: spec}
case expr.brace != nil:
return sedMatch{
start: baseIdx + int64(loc[0]),
end: baseIdx + int64(loc[1]),
oldText: oldText,
newText: expanded,
formats: braceExprToFormats(expr.brace),
braceExpr: expr.brace,
braceSpans: expr.braceSpans,
}
default:
plainText, formats := parseMarkdownReplacement(expanded)
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, newText: plainText, formats: formats}
}
}
// processFootnotes handles footnote matches, each needing a two-phase create+populate approach.
func processFootnotes(ctx context.Context, docsSvc *docs.Service, id string, footnoteMatches []sedMatch) error {
for i := len(footnoteMatches) - 1; i >= 0; i-- {
m := footnoteMatches[i]
fnReqs := []*docs.Request{
{DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
}},
{CreateFootnote: &docs.CreateFootnoteRequest{
Location: &docs.Location{Index: m.start},
}},
}
resp, err := batchUpdate(ctx, docsSvc, id, fnReqs)
if err != nil {
return fmt.Errorf("create footnote: %w", err)
}
// Find the footnote ID from the response and insert text into it
if resp != nil {
for _, reply := range resp.Replies {
if reply.CreateFootnote != nil && reply.CreateFootnote.FootnoteId != "" {
fnID := reply.CreateFootnote.FootnoteId
fnTextReqs := []*docs.Request{
{InsertText: &docs.InsertTextRequest{
Location: &docs.Location{
Index: 1, // footnote body starts at index 1
SegmentId: fnID,
},
Text: m.newText,
}},
}
if _, err := batchUpdate(ctx, docsSvc, id, fnTextReqs); err != nil {
return fmt.Errorf("populate footnote: %w", err)
}
break
}
}
}
}
return nil
}
// formatRange tracks a text range that needs formatting applied after insertion.
type formatRange struct {
start, end int64
formats []string
hasTab bool // replacement text starts with \t (nested list item)
braceExpr *braceExpr // SEDMAT v3.5 brace expression
braceSpans []*braceSpan // Inline scoping spans
}
func (c *DocsSedCmd) runManualInner(ctx context.Context, docsSvc *docs.Service, id string, expr sedExpr) (int, []*docs.Request, error) {
re, err := expr.compilePattern()
if err != nil {
return 0, nil, fmt.Errorf("compile pattern: %w", err)
}
doc, err := getDoc(ctx, docsSvc, id)
if err != nil {
return 0, nil, fmt.Errorf("get document: %w", err)
}
matches := findDocMatches(doc, re, expr)
if len(matches) == 0 {
return 0, nil, nil
}
// Build requests in reverse order
var requests []*docs.Request
var formatRanges []formatRange
// Separate footnote and image matches — they need special handling
var footnoteMatches []sedMatch
var imageMatches []sedMatch
var regularMatches []sedMatch
for _, m := range matches {
switch {
case containsFormat(m.formats, "footnote"):
footnoteMatches = append(footnoteMatches, m)
case m.image != nil:
imageMatches = append(imageMatches, m)
default:
regularMatches = append(regularMatches, m)
}
}
// Process image matches individually — Google Docs API cannot handle
// DeleteContentRange + InsertInlineImage in the same batch request
// (it fails to fetch the image URL when combined with other operations).
for i := len(imageMatches) - 1; i >= 0; i-- {
m := imageMatches[i]
// First: delete the matched text
deleteReqs := []*docs.Request{{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
},
}}
if _, err := batchUpdate(ctx, docsSvc, id, deleteReqs); err != nil {
return 0, nil, fmt.Errorf("delete before image insert: %w", err)
}
// Then: insert image in a separate API call
imgReq := &docs.InsertInlineImageRequest{
Uri: m.image.URL,
Location: &docs.Location{Index: m.start},
ObjectSize: buildImageSizeSpec(m.image),
}
if _, err := batchUpdate(ctx, docsSvc, id, []*docs.Request{{InsertInlineImage: imgReq}}); err != nil {
return 0, nil, fmt.Errorf("image insert (url=%s idx=%d): %w", m.image.URL, m.start, err)
}
}
for i := len(regularMatches) - 1; i >= 0; i-- {
m := regularMatches[i]
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
},
})
switch {
case containsFormat(m.formats, "hrule"):
// Horizontal rule: insert a newline, then style it with a bottom border
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: m.start},
Text: "\n",
},
})
requests = append(requests, buildHruleBorderRequest(m.start, m.start+1))
default:
if m.newText != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: m.start},
Text: m.newText,
},
})
}
if m.newText != "" && (len(m.formats) > 0 || m.braceExpr != nil) {
fmts := m.formats
if containsFormat(fmts, "codeblock") {
fmts = append(fmts, "code")
}
formatRanges = append(formatRanges, formatRange{
start: m.start,
end: m.start + int64(len(m.newText)),
formats: fmts,
hasTab: strings.HasPrefix(m.newText, "\t"),
braceExpr: m.braceExpr,
braceSpans: m.braceSpans,
})
}
}
}
// Add text-level formatting (bold, italic, code, super/sub, etc.)
for _, fr := range formatRanges {
if fr.braceExpr != nil {
// SEDMAT v3.5 brace syntax path
requests = append(requests, buildBraceTextStyleRequests(fr.braceExpr, fr.start, fr.end)...)
// Handle inline scoping spans
requests = append(requests, buildBraceInlineRequests(fr.braceSpans, fr.start)...)
} else {
requests = append(requests, buildTextStyleRequests(fr.formats, fr.start, fr.end)...)
}
}
// Split paragraph-level requests into bullet requests (deferred) and
// non-bullet requests (headings, blockquotes — applied immediately).
// Bullets are deferred so the caller can merge consecutive same-preset
// bullets into a single CreateParagraphBullets call, which is required
// for Google Docs to interpret leading \t as nesting levels.
var paraRequests []*docs.Request
var deferredBullets []*docs.Request
for _, fr := range formatRanges {
paraEnd := fr.end + 1
// Use brace paragraph formatting if available
if fr.braceExpr != nil && hasBraceParagraphFormat(fr.braceExpr) {
paraRequests = append(paraRequests, buildBraceParagraphStyleRequests(fr.braceExpr, fr.start, paraEnd)...)
} else {
for _, req := range buildParagraphStyleRequests(fr.formats, fr.start, paraEnd) {
if req.CreateParagraphBullets != nil && fr.hasTab {
// Nested bullets (have \t) are deferred so the caller can merge
// them with adjacent L0 bullets for proper nesting.
deferredBullets = append(deferredBullets, req)
} else {
paraRequests = append(paraRequests, req)
}
}
}
}
// Phase 1: inserts, deletes, text formatting
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return 0, nil, fmt.Errorf("update document: %w", err)
}
// Phase 2: non-bullet paragraph styles (headings, blockquotes)
if _, err := batchUpdate(ctx, docsSvc, id, paraRequests); err != nil {
return 0, nil, fmt.Errorf("apply paragraph styles: %w", err)
}
// Handle footnotes — each needs create + populate, processed individually in reverse
if err = processFootnotes(ctx, docsSvc, id, footnoteMatches); err != nil {
return 0, nil, err
}
// Phase 3: insert page/section/column break if {+=X} or {break=X} is set.
if err = applyBreakPhase(ctx, docsSvc, id, expr, formatRanges); err != nil {
return 0, nil, err
}
// Phase 4: Apply structural features (columns, checkboxes, bookmarks, smart chips).
// Requires re-fetching the document since text indices shifted in Phase 1.
if expr.brace != nil && hasBraceStructuralFeatures(expr.brace) {
freshDoc, err := getDoc(ctx, docsSvc, id)
if err != nil {
return 0, nil, fmt.Errorf("get doc for structural: %w", err)
}
// Collect all structural requests
var allStructuralReqs []*docs.Request
for _, fr := range formatRanges {
if fr.braceExpr == nil {
continue
}
// Get section boundaries for columns
sectionStart, sectionEnd := buildSectionRangeForMatch(freshDoc, fr.start, fr.end)
// Build structural requests
colReqs, bulletReqs, anchorReqs, chipReqs := buildStructuralRequests(
fr.braceExpr, fr.start, fr.end, sectionStart, sectionEnd,
)
allStructuralReqs = append(allStructuralReqs, colReqs...)
allStructuralReqs = append(allStructuralReqs, anchorReqs...)
allStructuralReqs = append(allStructuralReqs, chipReqs...)
// Add checkbox bullets to deferred bullets
deferredBullets = append(deferredBullets, bulletReqs...)
}
if _, err := batchUpdate(ctx, docsSvc, id, allStructuralReqs); err != nil {
return 0, nil, fmt.Errorf("apply structural features: %w", err)
}
}
return len(matches), deferredBullets, nil
}
// applyBreakPhase inserts page/section/column breaks after all text modifications.
func applyBreakPhase(ctx context.Context, docsSvc *docs.Service, id string, expr sedExpr, formatRanges []formatRange) error {
if expr.brace == nil || !expr.brace.HasBreak || len(formatRanges) == 0 {
return nil
}
freshDoc, err := getDoc(ctx, docsSvc, id)
if err != nil {
return fmt.Errorf("get doc for break: %w", err)
}
lastEnd := formatRanges[len(formatRanges)-1].end
breakIdx := lastEnd + 1
if freshDoc.Body != nil && len(freshDoc.Body.Content) > 0 {
bodyEnd := freshDoc.Body.Content[len(freshDoc.Body.Content)-1].EndIndex
if breakIdx >= bodyEnd {
breakIdx = bodyEnd - 1
}
}
breakReqs := buildBraceBreakRequests(expr.brace, breakIdx)
if _, err := batchUpdate(ctx, docsSvc, id, breakReqs); err != nil {
return fmt.Errorf("insert break: %w", err)
}
return nil
}

View File

@ -0,0 +1,242 @@
package cmd
import (
"context"
"strings"
"google.golang.org/api/docs/v1"
)
// applyDeferredBullets re-fetches the document and finds paragraphs that have
// leading \t characters (indicating pending bullet creation with nesting).
// It groups consecutive paragraphs by bullet preset and applies a single
// CreateParagraphBullets request per group, which allows Google Docs to
// interpret the tabs as nesting levels.
//
// Per the Google Docs API: "The nesting level of each paragraph is determined
// by counting leading tabs in front of each paragraph."
func (c *DocsSedCmd) applyDeferredBullets(ctx context.Context, docsSvc *docs.Service, id string) error {
var doc *docs.Document
err := retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
return e
})
if err != nil {
return err
}
if doc.Body == nil {
return nil
}
// Find paragraphs that need bullets. These are paragraphs with leading \t
// that don't already have a bullet (bullets were deferred).
// Also include non-tab paragraphs that are adjacent to tab paragraphs
// and match the same list type (they're the L0 parents).
type pendingBullet struct {
startIndex int64
endIndex int64
preset string
hasTab bool
}
var pending []pendingBullet
for _, elem := range doc.Body.Content {
if elem.Paragraph == nil {
continue
}
// Skip paragraphs that already have bullets
if elem.Paragraph.Bullet != nil {
continue
}
// Check text content for leading tab or bullet-like content
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun == nil {
continue
}
content := pe.TextRun.Content
hasTab := strings.HasPrefix(content, "\t")
if hasTab {
// This paragraph has a deferred nested bullet
pending = append(pending, pendingBullet{
startIndex: elem.StartIndex,
endIndex: elem.EndIndex,
preset: bulletPresetDisc, // default, will be refined
hasTab: true,
})
}
break // only check first text run
}
}
if len(pending) == 0 {
return nil
}
// Now we need to also find the L0 parent paragraphs. These are non-tab
// paragraphs immediately before a tab paragraph that were also bulleted
// (they already have bullets from their own runManualInner call).
// We need to include them in the same CreateParagraphBullets range.
//
// Strategy: expand each group to include adjacent bulleted paragraphs.
// Re-scan all paragraphs and build a map of paragraph ranges.
type paraInfo struct {
startIndex int64
endIndex int64
hasBullet bool
hasTab bool
preset string
}
var allParas []paraInfo
for _, elem := range doc.Body.Content {
if elem.Paragraph == nil {
continue
}
pi := paraInfo{
startIndex: elem.StartIndex,
endIndex: elem.EndIndex,
hasBullet: elem.Paragraph.Bullet != nil,
}
if pi.hasBullet {
pi.preset = inferBulletPreset(doc, elem.Paragraph.Bullet.ListId)
}
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun != nil {
pi.hasTab = strings.HasPrefix(pe.TextRun.Content, "\t")
break
}
}
allParas = append(allParas, pi)
}
// Find groups: consecutive paragraphs where at least one has a tab
// and all are either bulleted or have tabs (need bullets).
type group struct {
start, end int64
preset string
}
var groups []group
for i := 0; i < len(allParas); i++ {
p := allParas[i]
if !p.hasTab && !p.hasBullet {
continue
}
// Start a potential group
groupStart := p.startIndex
groupEnd := p.endIndex - 1
preset := p.preset
if preset == "" {
preset = bulletPresetDisc
}
hasAnyTab := p.hasTab
// Extend forward through adjacent bulleted/tab paragraphs of the same type.
// Break when the preset changes (e.g., bullet → numbered).
for i+1 < len(allParas) {
next := allParas[i+1]
if !next.hasTab && !next.hasBullet {
break
}
// Break if the next paragraph has a different preset (different list type)
if next.preset != "" && preset != "" && next.preset != preset {
break
}
i++
groupEnd = next.endIndex - 1
if next.hasTab {
hasAnyTab = true
}
if next.preset != "" {
preset = next.preset
}
}
// Only create a group if it contains at least one tab paragraph
if hasAnyTab {
groups = append(groups, group{start: groupStart, end: groupEnd, preset: preset})
}
}
if len(groups) == 0 {
return nil
}
// Get document body end index for clamping
var bodyEnd int64
if len(doc.Body.Content) > 0 {
bodyEnd = doc.Body.Content[len(doc.Body.Content)-1].EndIndex
}
// For each group: delete existing bullets (if any), then re-create merged
var requests []*docs.Request
for i := range groups {
g := &groups[i]
// Clamp end to the last content index. The paragraph endIndex is
// exclusive, and the body's final newline sits at the segment boundary.
// Use endIndex-1 to stay within valid range.
if g.end > bodyEnd-1 {
g.end = bodyEnd - 1
}
if g.start >= g.end {
continue
}
// Delete existing bullets first (some L0 items already have them)
requests = append(requests, &docs.Request{
DeleteParagraphBullets: &docs.DeleteParagraphBulletsRequest{
Range: &docs.Range{StartIndex: g.start, EndIndex: g.end},
},
})
// Re-create with merged range — tabs become nesting levels
requests = append(requests, &docs.Request{
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
Range: &docs.Range{StartIndex: g.start, EndIndex: g.end},
BulletPreset: g.preset,
},
})
}
// Process first group only — then recursively handle remaining groups
// by re-fetching the doc (indices shift when tabs are consumed by bullets).
if len(requests) >= 2 {
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests[:2],
}).Context(ctx).Do()
return e
})
if err != nil {
return err
}
// If more groups remain, re-run to pick them up with fresh indices
if len(requests) > 2 {
return c.applyDeferredBullets(ctx, docsSvc, id)
}
}
return nil
}
// inferBulletPreset determines the bullet preset from the list properties.
func inferBulletPreset(doc *docs.Document, listID string) string {
if doc.Lists == nil {
return bulletPresetDisc
}
list, ok := doc.Lists[listID]
if !ok || list.ListProperties == nil {
return bulletPresetDisc
}
levels := list.ListProperties.NestingLevels
if len(levels) > 0 && levels[0] != nil {
switch levels[0].GlyphType {
case "DECIMAL", "ZERO_DECIMAL", "UPPER_ALPHA", "ALPHA",
"UPPER_ROMAN", "ROMAN":
return "NUMBERED_DECIMAL_NESTED"
}
}
return bulletPresetDisc
}

View File

@ -0,0 +1,543 @@
package cmd
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
const backrefPlaceholder = "\x00BACKREF_"
// sedBackrefRe matches sed-style backreferences (\1 through \9).
var sedBackrefRe = regexp.MustCompile(`\\(\d)`)
var backrefReplacer = strings.NewReplacer(func() []string {
var pairs []string
for d := 1; d <= 9; d++ {
pairs = append(pairs, backrefPlaceholder+strconv.Itoa(d)+"\x00", fmt.Sprintf("${%d}", d))
}
return pairs
}()...)
// parseSedExpr parses a sed expression (s/pattern/replacement/flags) into its components.
// It handles escaped delimiters, backref conversion ($N), and & whole-match expansion.
func parseSedExpr(expr string) (pattern, replacement string, global bool, err error) {
if len(expr) < 4 || expr[0] != 's' {
return "", "", false, fmt.Errorf("invalid sed expression (expected s/pattern/replacement/[flags])")
}
delim := expr[1]
// Split respecting escaped delimiters (\/ when delim is /)
parts := splitByDelim(expr[2:], delim)
if len(parts) < 2 {
return "", "", false, fmt.Errorf("invalid sed expression (missing replacement)")
}
pattern = parts[0]
replacement = parts[1]
// Check flags
flags := flagsFromParts(parts, 2)
global = strings.Contains(flags, "g")
pattern = applyRegexFlags(pattern, flags)
// Process replacement escapes and backreferences.
// In sed replacements: \1-\9 are backrefs, \$ is literal $, \. is literal ., \\ is literal \, etc.
// In Go regex replacements: $1/${1} are backrefs, $$ is literal $.
// Step 1: Convert sed backrefs \1-\9 to placeholders
replacement = sedBackrefRe.ReplaceAllString(replacement, backrefPlaceholder+"${1}\x00")
// Step 2: Process replacement string — unescape sed escapes and handle $ for Go regex.
// In one pass: \$ → literal dollar (escaped as $$ for Go), \. → ., \\ → \,
// $N → backref placeholder, other $ → $$ (literal for Go).
// Preserve \n, \t for later processing.
var processed strings.Builder
for i := 0; i < len(replacement); i++ {
switch {
case replacement[i] == '\\' && i+1 < len(replacement):
next := replacement[i+1]
switch next {
case '$':
// \$ in sed replacement = literal $ → escape as $$ for Go regex
processed.WriteString("$$")
i++
case '.', '^', '[', ']', '(', ')', '{', '}', '+', '?', '|':
// Unescape regex metacharacters to literals
processed.WriteByte(next)
i++
case '&':
// \& = literal & (not a whole-match backref) — use placeholder
processed.WriteString("\x00LITAMP\x00")
i++
case '\\':
processed.WriteByte('\\')
i++
default:
// Preserve other escapes (\n, \t, etc.)
processed.WriteByte('\\')
}
case replacement[i] == '$':
switch {
case i+1 < len(replacement) && replacement[i+1] == '$':
// $$ in sed replacement = literal $ → escape as $$ for Go regex
processed.WriteString("$$")
i++ // skip second $
case i+1 < len(replacement) && replacement[i+1] >= '1' && replacement[i+1] <= '9':
// $N backref — convert to placeholder
processed.WriteString(backrefPlaceholder)
processed.WriteByte(replacement[i+1])
processed.WriteByte('\x00')
i++
case i+1 < len(replacement) && replacement[i+1] == '{':
// ${N} backref — pass through
processed.WriteByte('$')
default:
// Literal $ not followed by digit or $ — escape for Go regex
processed.WriteString("$$")
}
default:
processed.WriteByte(replacement[i])
}
}
replacement = processed.String()
// Step 3: Restore backrefs from placeholders → Go-style ${N}
replacement = backrefReplacer.Replace(replacement)
// Step 4: Handle sed & (whole match) → Go's ${0}
// Unescaped & means "whole match" in sed. \& was converted to \x00LITAMP\x00 in Step 2.
replacement = strings.ReplaceAll(replacement, "&", "${0}")
replacement = strings.ReplaceAll(replacement, "\x00LITAMP\x00", "&")
return pattern, replacement, global, nil
}
// parseSedExprWithCell parses a sed expression and extracts any table cell reference from the pattern.
// parseTableRef checks if a pattern is a bare table reference like |1|, |2|, |-1|, |*|
// Returns (tableIndex, ok). tableIndex is 1-indexed, negative from end, 0 means |*| (all).
func parseTableRef(s string) (int, bool) {
s = strings.TrimSpace(s)
if len(s) < 3 || s[0] != '|' || s[len(s)-1] != '|' {
return 0, false
}
inner := s[1 : len(s)-1]
// Don't match table creation specs (contain 'x')
if strings.ContainsAny(inner, "xX") {
return 0, false
}
if inner == "*" {
return math.MinInt32, true // all tables
}
n, err := strconv.Atoi(inner)
if err != nil {
return 0, false
}
if n == 0 {
return 0, false
}
return n, true
}
// Cell ref patterns: s/|1|[2,3]/replacement/ or s/|1|[A1]:subpattern/replacement/
func parseSedExprWithCell(expr string) (pattern, replacement string, global bool, cellRef *tableCellRef, err error) {
pattern, replacement, global, err = parseSedExpr(expr)
if err != nil {
return
}
// Check if pattern is a table cell reference
ref := parseTableCellRef(pattern)
if ref != nil {
cellRef = ref
if ref.subPattern != "" {
pattern = ref.subPattern
} else {
pattern = "" // whole cell replacement
}
}
return
}
// parseMarkdownReplacement extracts text and formatting from markdown-style replacement.
// Supported standard CommonMark formats: **bold**, *italic*, ***bold+italic***,
// ~~strikethrough~~, `code`, [text](url), # through ###### headings,
// - bullets, 1. numbered, > blockquotes, --- hrules, ```codeblocks```, [^footnotes].
// Escape sequences: \*, \#, \~, \`, \-, \+, \\, \n.
// Returns: plain text, format strings (e.g., "bold", "heading2", "link:url").
func parseMarkdownReplacement(repl string) (text string, formats []string) {
text = repl
// Process escape sequences and restore on return
text = escapeMarkdown(text)
defer func() { text = unescapeMarkdown(text) }()
// Horizontal rule (---, ***, ___) — must be exactly 3 of the same char
// (not 4+ which could be bold/italic markers)
trimmed := strings.TrimSpace(text)
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
return "\n", []string{"hrule"}
}
// Fenced code block (```...```)
if strings.HasPrefix(text, "```") && strings.HasSuffix(text, "```") && len(text) > 6 {
inner := text[3 : len(text)-3]
// Strip optional language hint on first line (e.g., ```go)
if idx := strings.Index(inner, "\n"); idx >= 0 {
inner = inner[idx+1:]
}
return inner, append(formats, "codeblock")
}
// Blockquote (> text)
if strings.HasPrefix(text, "> ") {
return text[2:], append(formats, "blockquote")
}
// Footnote [^text] — creates a footnote with the given text
if strings.HasPrefix(text, "[^") && strings.HasSuffix(text, "]") && len(text) > 3 {
footnoteText := text[2 : len(text)-1]
return footnoteText, []string{"footnote"}
}
// Check for list prefixes with optional indentation for nesting
// Detect indent level: 2 spaces = 1 level, 4 spaces = 2 levels, etc.
indentLevel := 0
listText := text
for strings.HasPrefix(listText, " ") {
indentLevel++
listText = listText[2:]
}
listFormat := ""
switch {
case strings.HasPrefix(listText, "- "):
text = listText[2:]
listFormat = "bullet"
case strings.HasPrefix(listText, "* ") && !strings.HasSuffix(listText, "*"):
text = listText[2:]
listFormat = "bullet"
case len(listText) > 2 && listText[0] >= '0' && listText[0] <= '9' && listText[1] == '.' && listText[2] == ' ':
text = listText[3:]
listFormat = "numbered"
}
if listFormat != "" {
formats = append(formats, listFormat)
if indentLevel > 0 {
// Prepend tab characters for nesting. When CreateParagraphBullets
// is applied, Google Docs converts leading \t into nesting levels.
text = strings.Repeat("\t", indentLevel) + text
}
}
// Bold+italic (***text***)
if strings.HasPrefix(text, "***") && strings.HasSuffix(text, "***") && len(text) > 6 {
return text[3 : len(text)-3], append(formats, "bold", "italic")
}
// Bold (**text**)
if strings.HasPrefix(text, "**") && strings.HasSuffix(text, "**") && len(text) > 4 {
return text[2 : len(text)-2], append(formats, "bold")
}
// Italic (*text*) - only if not already parsed as bullet
if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") && len(text) > 2 {
return text[1 : len(text)-1], append(formats, "italic")
}
// Strikethrough (~~text~~)
if strings.HasPrefix(text, "~~") && strings.HasSuffix(text, "~~") && len(text) > 4 {
return text[2 : len(text)-2], append(formats, "strikethrough")
}
// Code (`text`)
if strings.HasPrefix(text, "`") && strings.HasSuffix(text, "`") && len(text) > 2 {
return text[1 : len(text)-1], append(formats, "code")
}
// Link [text](url) — can appear anywhere in the replacement
if idx := strings.Index(text, "]("); idx > 0 && strings.HasPrefix(text, "[") {
closeParen := strings.LastIndex(text, ")")
if closeParen > idx+2 {
linkText := text[1:idx]
linkURL := text[idx+2 : closeParen]
linkURL = strings.ReplaceAll(linkURL, "\\/", "/")
return linkText, append(formats, "link:"+linkURL)
}
}
// Headings (#text, ##text, etc)
if strings.HasPrefix(text, "#") {
level := 0
for i := 0; i < len(text) && i < 6; i++ {
if text[i] == '#' {
level++
} else {
break
}
}
if level > 0 && level <= 6 {
stripped := strings.TrimPrefix(text[level:], " ")
return stripped, append(formats, fmt.Sprintf("heading%d", level))
}
}
return text, formats
}
// parseFullExpr parses a raw expression string into a sedExpr, handling all command types
// (s//, d//, a//, i//, y//) and flags (g, i, m, N for nth occurrence).
func parseFullExpr(raw string) (sedExpr, error) {
if len(raw) == 0 {
return sedExpr{}, fmt.Errorf("empty expression")
}
// Check for non-substitution commands: d, a, i, y
// Only treat as command if followed by a non-alphanumeric delimiter (like /)
if len(raw) >= 2 && !isAlphanumeric(raw[1]) {
switch raw[0] {
case 'd':
return parseDCommand(raw)
case 'a':
return parseAICommand(raw, 'a')
case 'i':
return parseAICommand(raw, 'i')
case 'y':
return parseYCommand(raw)
}
}
// Standard s// command
pattern, replacement, global, cellRef, err := parseSedExprWithCell(raw)
if err != nil {
return sedExpr{}, err
}
expr := sedExpr{pattern: pattern, replacement: replacement, global: global, cellRef: cellRef}
// Check for brace pattern-side addressing: {T=...} or {img=...}
// This is SEDMAT v3.5 syntax for table/image addressing in the pattern position
if cellRef == nil && strings.HasPrefix(pattern, "{") {
remaining, tableRef, imgRef, braceErr := detectBracePattern(pattern)
if braceErr != nil {
return sedExpr{}, fmt.Errorf("brace pattern: %w", braceErr)
}
if tableRef != nil {
// Bridge to existing table machinery
braceTableToSedExpr(tableRef, &expr)
expr.pattern = remaining
// If this is a table creation spec, handle via replacement
if tableRef.IsCreate {
spec := braceTableToTableCreateSpec(tableRef)
if spec != nil {
// Convert to pipe-style for existing machinery
if spec.header {
expr.replacement = fmt.Sprintf("|%dx%d:header|", spec.rows, spec.cols)
} else {
expr.replacement = fmt.Sprintf("|%dx%d|", spec.rows, spec.cols)
}
}
}
}
if imgRef != nil {
// Bridge to existing image machinery via pattern
imgPattern := braceImgToImageRefPattern(imgRef)
if imgPattern != nil {
switch {
case imgPattern.AllImages:
expr.pattern = "!(*)"
case imgPattern.ByPosition:
expr.pattern = fmt.Sprintf("!(%d)", imgPattern.Position)
case imgPattern.ByAlt && imgPattern.AltRegex != nil:
expr.pattern = fmt.Sprintf("![%s]", imgRef.Pattern)
}
}
}
}
// Check if pattern is a bare table reference (|1|, |-1|, |*|) — legacy pipe syntax
if expr.cellRef == nil && expr.tableRef == 0 {
if tIdx, ok := parseTableRef(expr.pattern); ok {
expr.tableRef = tIdx
expr.pattern = ""
}
}
// Extract nth-match flag from raw expression flags
expr.nthMatch = parseNthFlag(raw)
// Check for brace formatting in replacement (SEDMAT v3.5 syntax)
if hasBraceFormatting(replacement) {
cleanedText, spans := findBraceExprs(replacement)
if len(spans) > 0 {
expr.replacement = cleanedText
expr.braceSpans = spans
// If there's exactly one global span, use it as the main brace expr
if len(spans) == 1 && spans[0].IsGlobal {
expr.brace = spans[0].Expr
} else {
// Multiple spans or non-global: merge into one brace for global formatting
expr.brace = mergeBraceSpans(spans)
}
// Handle {T=NxM} table creation in replacement position:
// convert to pipe-style spec for existing table machinery.
if expr.brace != nil && expr.brace.TableRef != "" {
bt, btErr := parseBraceTableRef(expr.brace.TableRef)
if btErr == nil && bt.IsCreate {
spec := braceTableToTableCreateSpec(bt)
if spec != nil {
if spec.header {
expr.replacement = fmt.Sprintf("|%dx%d:header|", spec.rows, spec.cols)
} else {
expr.replacement = fmt.Sprintf("|%dx%d|", spec.rows, spec.cols)
}
expr.brace = nil
expr.braceSpans = nil
}
}
}
}
}
return expr, nil
}
// parseNthFlag extracts the Nth occurrence flag (e.g., "2" in s/foo/bar/2) from a raw expression.
func parseNthFlag(raw string) int {
if len(raw) < 4 || raw[0] != 's' {
return 0
}
parts := splitByDelim(raw[2:], raw[1])
flags := flagsFromParts(parts, 2)
return extractNumber(flags)
}
// extractNumber returns the first contiguous positive integer found in a string, or 0.
// Ignores content inside {attrs} blocks.
func extractNumber(s string) int {
if idx := strings.Index(s, "{"); idx >= 0 {
s = s[:idx]
}
// Find first digit run
start := -1
for i, c := range s {
if c >= '0' && c <= '9' {
if start < 0 {
start = i
}
} else if start >= 0 {
break
}
}
if start < 0 {
return 0
}
end := start
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
n, err := strconv.Atoi(s[start:end])
if err != nil || n <= 0 {
return 0
}
return n
}
// parseDCommand parses a delete command: d/pattern/ or d/pattern/flags
// Deletes all text matching the pattern (entire line containing match).
func parseDCommand(raw string) (sedExpr, error) {
if len(raw) < 3 || raw[0] != 'd' {
return sedExpr{}, fmt.Errorf("invalid delete command (expected d/pattern/)")
}
parts := splitByDelim(raw[2:], raw[1])
if len(parts) < 1 || parts[0] == "" {
return sedExpr{}, fmt.Errorf("invalid delete command (empty pattern)")
}
pattern := applyRegexFlags(parts[0], flagsFromParts(parts, 1))
return sedExpr{pattern: pattern, command: 'd'}, nil
}
// parseAICommand parses append/insert commands: a/pattern/text/ or i/pattern/text/
// 'a' appends text after the matched line; 'i' inserts text before the matched line.
func parseAICommand(raw string, cmd byte) (sedExpr, error) {
if len(raw) < 3 || raw[0] != cmd {
return sedExpr{}, fmt.Errorf("invalid %c command", cmd)
}
parts := splitByDelim(raw[2:], raw[1])
if len(parts) < 2 {
return sedExpr{}, fmt.Errorf("invalid %c command (expected %c/pattern/text/)", cmd, cmd)
}
pattern := applyRegexFlags(parts[0], flagsFromParts(parts, 2))
return sedExpr{pattern: pattern, replacement: parts[1], command: cmd}, nil
}
// flagsFromParts returns the flags string from parts[idx] if it exists, or empty string.
func flagsFromParts(parts []string, idx int) string {
if idx < len(parts) {
return parts[idx]
}
return ""
}
// applyRegexFlags prepends Go regex flag syntax for i (case-insensitive) and m (multiline).
func applyRegexFlags(pattern, flags string) string {
// Strip {attrs} block from flags before checking flag characters
if idx := strings.Index(flags, "{"); idx >= 0 {
flags = flags[:idx]
}
if strings.Contains(flags, "i") {
pattern = "(?i)" + pattern
}
if strings.Contains(flags, "m") {
pattern = "(?m)" + pattern
}
return pattern
}
// parseYCommand parses a transliterate command: y/source/dest/
// Each character in source is replaced by the corresponding character in dest.
func parseYCommand(raw string) (sedExpr, error) {
if len(raw) < 3 || raw[0] != 'y' {
return sedExpr{}, fmt.Errorf("invalid transliterate command (expected y/source/dest/)")
}
parts := splitByDelim(raw[2:], raw[1])
if len(parts) < 2 {
return sedExpr{}, fmt.Errorf("invalid transliterate command (expected y/source/dest/)")
}
source := parts[0]
dest := parts[1]
if len([]rune(source)) != len([]rune(dest)) {
return sedExpr{}, fmt.Errorf("transliterate: source and dest must have same length (%d vs %d)", len([]rune(source)), len([]rune(dest)))
}
if source == "" {
return sedExpr{}, fmt.Errorf("transliterate: empty source")
}
return sedExpr{pattern: source, replacement: dest, command: 'y'}, nil
}
// splitByDelim splits a string by an unescaped delimiter character.
func splitByDelim(s string, delim byte) []string {
var parts []string
var current strings.Builder
for i := 0; i < len(s); i++ {
switch {
case s[i] == '\\' && i+1 < len(s) && s[i+1] == delim:
current.WriteByte(delim)
i++
case s[i] == delim:
parts = append(parts, current.String())
current.Reset()
default:
current.WriteByte(s[i])
}
}
parts = append(parts, current.String())
return parts
}
// isAlphanumeric returns true if b is a letter or digit.
func isAlphanumeric(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// (Legacy parseAttrsFromRaw, parseAttrs, sedAttrs removed — use brace syntax instead)

View File

@ -0,0 +1,84 @@
package cmd
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
gapi "google.golang.org/api/googleapi"
)
const (
maxRetries = 5
baseDelay = 1 * time.Second
maxDelay = 30 * time.Second
)
// retryOnQuota retries fn on 429 (rate limit) and 500/503 (transient server) errors
// with exponential backoff + jitter.
func retryOnQuota(ctx context.Context, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
err := fn()
if err == nil {
return nil
}
lastErr = err
// Check if retryable
if !isRetryableError(err) {
return err
}
// Don't retry if we've exhausted attempts
if attempt == maxRetries {
return fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
// Exponential backoff with jitter
delay := baseDelay * time.Duration(1<<uint(attempt))
if delay > maxDelay {
delay = maxDelay
}
// Add jitter: 50-100% of delay (crypto/rand for linter compliance)
var randBuf [8]byte
_, _ = rand.Read(randBuf[:])
halfDelay := int64(delay / 2)
var jitter time.Duration
if halfDelay > 0 {
jitter = time.Duration(binary.LittleEndian.Uint64(randBuf[:]) % uint64(halfDelay)) //nolint:gosec // jitter value is bounded
}
delay = delay/2 + jitter
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return lastErr
}
// isRetryableError returns true for transient Google API errors (429, 500, 502, 503)
// that are safe to retry with exponential backoff.
func isRetryableError(err error) bool {
if err == nil {
return false
}
var apiErr *gapi.Error
if ok := errors.As(err, &apiErr); ok {
switch apiErr.Code {
case 429: // rate limit
return true
case 500, 502, 503: // transient server errors
return true
}
}
// Also check for string match as fallback (some errors don't use googleapi.Error)
errStr := err.Error()
return strings.Contains(errStr, "rateLimitExceeded") || strings.Contains(errStr, "429")
}

View File

@ -0,0 +1,317 @@
package cmd
import (
"context"
"fmt"
"sort"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
// runTableCellReplace replaces content in a specific table cell, handling both
// whole-cell and sub-pattern replacements with optional formatting.
func (c *DocsSedCmd) runTableCellReplace(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
ref := expr.cellRef
// Route row/col operations to dedicated handler
if ref.rowOp != "" || ref.colOp != "" {
return c.runTableRowColOp(ctx, u, account, id, expr)
}
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
// Handle wildcard ranges: iterate over matching cells
if ref.row == 0 || ref.col == 0 {
return c.runTableWildcardReplace(ctx, docsSvc, u, id, doc, expr)
}
cell, err := findTableCell(doc, ref)
if err != nil {
return fmt.Errorf("find table cell: %w", err)
}
cellText, startIdx, endIdx := getCellText(cell)
var requests []*docs.Request
var newText string
if expr.pattern == "" {
// Whole cell replacement: replace entire cell content
// Expand ${0} (whole match / sed &) to existing cell text
trimmedCell := strings.TrimRight(cellText, "\n")
r := literalReplacement(expr.replacement)
r = strings.ReplaceAll(r, "${0}", trimmedCell)
// Parse markdown formatting
plainText, formats := parseMarkdownReplacement(r)
newText = plainText
// Strip trailing newline from cell text (cells always end with \n)
deleteEnd := endIdx
if len(cellText) > 0 && cellText[len(cellText)-1] == '\n' {
deleteEnd = endIdx - 1 // keep the trailing newline
}
requests = append(requests, buildCellReplaceRequests(startIdx, deleteEnd, newText, formats)...)
} else {
// Sub-pattern replacement within the cell
re, reErr := expr.compilePattern()
if reErr != nil {
return fmt.Errorf("compile pattern: %w", reErr)
}
// Find matches within cell text
type cellMatch struct {
start, end int64
newText string
}
var matches []cellMatch
// Use LiteralString unless replacement contains backreferences like ${1}
replaceFunc := re.ReplaceAllLiteralString
if strings.Contains(expr.replacement, "${") || strings.Contains(expr.replacement, "$\\") {
replaceFunc = re.ReplaceAllString
}
if expr.global {
results := re.FindAllStringIndex(cellText, -1)
for _, loc := range results {
oldText := cellText[loc[0]:loc[1]]
replaced := replaceFunc(oldText, expr.replacement)
matches = append(matches, cellMatch{
start: startIdx + int64(loc[0]),
end: startIdx + int64(loc[1]),
newText: replaced,
})
}
} else {
loc := re.FindStringIndex(cellText)
if loc != nil {
oldText := cellText[loc[0]:loc[1]]
replaced := replaceFunc(oldText, expr.replacement)
matches = append(matches, cellMatch{
start: startIdx + int64(loc[0]),
end: startIdx + int64(loc[1]),
newText: replaced,
})
}
}
// Build requests in reverse order
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: m.start,
EndIndex: m.end,
},
},
})
if m.newText != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: m.start},
Text: m.newText,
},
})
}
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
}
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update: %w", err)
}
replaced := 1
if expr.pattern != "" && expr.global {
replaced = (len(requests) + 1) / 2 // each match = delete + insert
}
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", replaced})
}
// runBatchCellReplace batches multiple whole-cell replacements for the same table into one API call.
func (c *DocsSedCmd) runBatchCellReplace(ctx context.Context, _ *ui.UI, account, id string, exprs []indexedExpr) error {
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
type cellOp struct {
startIdx, endIdx int64
cellText string
replacement string
}
var ops []cellOp
for _, ie := range exprs {
cell, findErr := findTableCell(doc, ie.expr.cellRef)
if findErr != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, findErr)
}
cellText, startIdx, endIdx := getCellText(cell)
ops = append(ops, cellOp{startIdx: startIdx, endIdx: endIdx, cellText: cellText, replacement: ie.expr.replacement})
}
// Sort by startIdx descending (reverse document order)
sort.Slice(ops, func(i, j int) bool {
return ops[i].startIdx > ops[j].startIdx
})
var requests []*docs.Request
for _, op := range ops {
trimmedCell := strings.TrimRight(op.cellText, "\n")
r := literalReplacement(op.replacement)
r = strings.ReplaceAll(r, "${0}", trimmedCell)
plainText, formats := parseMarkdownReplacement(r)
deleteEnd := op.endIdx
if len(op.cellText) > 0 && op.cellText[len(op.cellText)-1] == '\n' {
deleteEnd = op.endIdx - 1
}
requests = append(requests, buildCellReplaceRequests(op.startIdx, deleteEnd, plainText, formats)...)
}
if len(requests) > 0 {
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: requests}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch cell update: %w", err)
}
}
return nil
}
// runTableWildcardReplace handles cell references with wildcards: |1|[1,*], |1|[*,2], |1|[*,*]
func (c *DocsSedCmd) runTableWildcardReplace(ctx context.Context, docsSvc *docs.Service, u *ui.UI, id string, doc *docs.Document, expr sedExpr) error {
ref := expr.cellRef
tables := collectAllTables(doc)
if len(tables) == 0 {
return fmt.Errorf("document has no tables")
}
ti := ref.tableIndex
if ti < 0 {
ti = len(tables) + ti + 1
}
if ti < 1 || ti > len(tables) {
return fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tables))
}
table := tables[ti-1]
// Collect all matching cells
type cellInfo struct {
startIdx int64
endIdx int64
text string
}
var cells []cellInfo
for ri, row := range table.TableRows {
for ci, cell := range row.TableCells {
// Check if this cell matches the wildcard pattern
rowMatch := ref.row == 0 || ref.row == ri+1
colMatch := ref.col == 0 || ref.col == ci+1
if rowMatch && colMatch {
text, start, end := getCellText(cell)
cells = append(cells, cellInfo{startIdx: start, endIdx: end, text: text})
}
}
}
if len(cells) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
}
// Build requests in reverse order (to preserve indices)
var requests []*docs.Request
replaced := 0
for i := len(cells) - 1; i >= 0; i-- {
cell := cells[i]
// Parse the replacement per-cell (need to expand ${0} with cell content)
cellRepl := literalReplacement(expr.replacement)
if expr.pattern == "" {
// Expand ${0} (sed &) to existing cell text
trimmedCell := strings.TrimRight(cell.text, "\n")
cellRepl = strings.ReplaceAll(cellRepl, "${0}", trimmedCell)
}
plainText, formats := parseMarkdownReplacement(cellRepl)
if expr.pattern == "" {
// Whole cell replacement
deleteEnd := cell.endIdx
if len(cell.text) > 0 && cell.text[len(cell.text)-1] == '\n' {
deleteEnd = cell.endIdx - 1
}
requests = append(requests, buildCellReplaceRequests(cell.startIdx, deleteEnd, plainText, formats)...)
replaced++
} else {
// Sub-pattern replacement within matching cells
re, err := expr.compilePattern()
if err != nil {
return fmt.Errorf("compile pattern: %w", err)
}
results := re.FindAllStringIndex(cell.text, -1)
if !expr.global && len(results) > 1 {
results = results[:1]
}
for j := len(results) - 1; j >= 0; j-- {
loc := results[j]
start := cell.startIdx + int64(loc[0])
end := cell.startIdx + int64(loc[1])
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
},
})
if plainText != "" {
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: start},
Text: plainText,
},
})
}
replaced++
}
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
}
err := retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (wildcard cell replace): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", replaced})
}

View File

@ -0,0 +1,318 @@
package cmd
import (
"context"
"fmt"
"strconv"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
type tableCreateSpec struct {
rows int
cols int
header bool // pin first row as header
cells [][]string // optional cell content for pipe-table syntax
}
// parseTableFromPipes detects markdown-style pipe tables like:
//
// | Name | Role | Status |
// | Alice | Engineer | Active |
// | Bob | Designer | Active |
//
// Returns a tableCreateSpec with rows, cols, and cell content filled in.
// Returns nil if the replacement is not a pipe table.
func parseTableFromPipes(s string) *tableCreateSpec {
// Convert escaped newlines to real newlines (sed replacements use \n)
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "|") {
return nil
}
lines := strings.Split(s, "\n")
if len(lines) < 1 {
return nil
}
var rows [][]string
colCount := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.HasPrefix(line, "|") {
return nil // not a pipe table
}
// Split by | and trim
parts := strings.Split(line, "|")
var cells []string
for _, p := range parts {
p = strings.TrimSpace(p)
// Skip empty parts from leading/trailing |
if p == "" {
continue
}
// Skip separator rows like |---|---|
if strings.Trim(p, "-: ") == "" {
cells = nil
break
}
cells = append(cells, p)
}
if cells == nil {
continue // skip separator row
}
if len(cells) == 0 {
return nil
}
if colCount == 0 {
colCount = len(cells)
} else if len(cells) != colCount {
// Pad or truncate to match first row
for len(cells) < colCount {
cells = append(cells, "")
}
cells = cells[:colCount]
}
rows = append(rows, cells)
}
if len(rows) < 1 || colCount < 1 {
return nil
}
return &tableCreateSpec{
rows: len(rows),
cols: colCount,
cells: rows,
}
}
// parseTableCreate checks if a replacement string is a table creation spec like |3x4| or |3x4:header|
// Returns nil if it's not a table creation spec.
func parseTableCreate(s string) *tableCreateSpec {
s = strings.TrimSpace(s)
if len(s) < 4 || s[0] != '|' || s[len(s)-1] != '|' {
return nil
}
inner := s[1 : len(s)-1]
// Check for :header suffix
header := false
if idx := strings.Index(inner, ":"); idx >= 0 {
suffix := strings.ToLower(strings.TrimSpace(inner[idx+1:]))
if suffix != "header" {
return nil
}
header = true
inner = inner[:idx]
}
// Parse RxC
inner = strings.ToLower(inner)
parts := strings.SplitN(inner, "x", 2)
if len(parts) != 2 {
return nil
}
rows, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
cols, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil || rows < 1 || cols < 1 || rows > 100 || cols > 26 {
return nil
}
return &tableCreateSpec{rows: rows, cols: cols, header: header}
}
// runTableCreate handles creating a table at the location of matched text
// fillTableCells populates a newly-created table with cell content from spec.cells.
// nearIndex is the approximate document index where the table was inserted.
func (c *DocsSedCmd) fillTableCells(ctx context.Context, docsSvc *docs.Service, id string, nearIndex int64, spec *tableCreateSpec) error {
var doc *docs.Document
err := retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("re-fetch document after table create: %w", err)
}
tables := collectAllTables(doc)
var targetTable *docs.Table
for _, t := range tables {
if len(t.TableRows) > 0 && len(t.TableRows[0].TableCells) > 0 {
firstCell := t.TableRows[0].TableCells[0]
if len(firstCell.Content) > 0 {
cellStart := firstCell.Content[0].StartIndex
if cellStart >= nearIndex && cellStart <= nearIndex+10 {
targetTable = t
break
}
}
}
}
if targetTable == nil {
return nil // table not found, skip filling
}
var fillRequests []*docs.Request
// Iterate in reverse order so indices remain valid after inserts
for r := len(targetTable.TableRows) - 1; r >= 0; r-- {
row := targetTable.TableRows[r]
for ci := len(row.TableCells) - 1; ci >= 0; ci-- {
cell := row.TableCells[ci]
if r >= len(spec.cells) || ci >= len(spec.cells[r]) {
continue
}
cellText := spec.cells[r][ci]
if cellText == "" {
continue
}
if len(cell.Content) == 0 {
continue
}
// In a table cell, the first StructuralElement is a paragraph.
// For an empty cell, the paragraph occupies [startIndex, startIndex+1] with just a \n.
// We insert at startIndex to place text before the trailing newline.
insertIdx := cell.Content[0].StartIndex
plainText, formats := parseMarkdownReplacement(cellText)
fillRequests = append(fillRequests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: plainText,
},
})
fillRequests = append(fillRequests, buildTextStyleRequests(formats, insertIdx, insertIdx+int64(len(plainText)))...)
}
}
if len(fillRequests) > 0 {
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: fillRequests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (fill table cells): %w", err)
}
}
return nil
}
func (c *DocsSedCmd) runTableCreate(ctx context.Context, u *ui.UI, account, id string, expr sedExpr, spec *tableCreateSpec) error {
re, err := expr.compilePattern()
if err != nil {
return fmt.Errorf("compile pattern: %w", err)
}
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
// Find the placeholder text in the document
var matchStart, matchEnd int64
found := false
var walkContent func(content []*docs.StructuralElement)
walkContent = func(content []*docs.StructuralElement) {
if found {
return
}
for _, elem := range content {
if elem.Paragraph != nil {
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun != nil && pe.TextRun.Content != "" {
loc := re.FindStringIndex(pe.TextRun.Content)
if loc != nil {
matchStart = pe.StartIndex + int64(loc[0])
matchEnd = pe.StartIndex + int64(loc[1])
found = true
return
}
}
}
}
// Walk into table cells too
if elem.Table != nil {
for _, row := range elem.Table.TableRows {
for _, cell := range row.TableCells {
walkContent(cell.Content)
}
}
}
}
}
if doc.Body != nil {
walkContent(doc.Body.Content)
}
if !found {
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "pattern not found"})
}
// Step 1: Delete the placeholder text
// Step 2: Insert the table at that position
// Note: InsertTableRequest requires the location to be inside a paragraph,
// so we insert at the start of the match.
var requests []*docs.Request
// Delete placeholder text
if matchStart < matchEnd {
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: matchStart,
EndIndex: matchEnd,
},
},
})
}
// Insert table at the position where placeholder was
requests = append(requests, &docs.Request{
InsertTable: &docs.InsertTableRequest{
Location: &docs.Location{Index: matchStart},
Rows: int64(spec.rows),
Columns: int64(spec.cols),
},
})
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (create table): %w", err)
}
// Fill cells with content if provided (pipe-table syntax)
if len(spec.cells) > 0 {
if err := c.fillTableCells(ctx, docsSvc, id, matchStart, spec); err != nil {
return fmt.Errorf("fill table cells: %w", err)
}
}
extra := []sedOutputKV{{"created", fmt.Sprintf("%dx%d table", spec.rows, spec.cols)}}
if len(spec.cells) > 0 {
extra = append(extra, sedOutputKV{"filled", true})
}
if spec.header {
extra = append(extra, sedOutputKV{"header", "true (note: header pinning requires manual step in Docs UI)"})
}
return sedOutputOK(ctx, u, id, extra...)
}

View File

@ -0,0 +1,266 @@
package cmd
import (
"context"
"fmt"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
// runTableRowColOp handles row and column operations: insert, delete, and append.
func (c *DocsSedCmd) runTableRowColOp(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
ref := expr.cellRef
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
tablesWithIdx := collectAllTablesWithIndex(doc)
if len(tablesWithIdx) == 0 {
return fmt.Errorf("document has no tables")
}
// Resolve table index
ti := ref.tableIndex
if ti < 0 {
ti = len(tablesWithIdx) + ti + 1
}
if ti < 1 || ti > len(tablesWithIdx) {
return fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tablesWithIdx))
}
tw := tablesWithIdx[ti-1]
table := tw.table
tableStartLoc := &docs.Location{Index: tw.startIdx}
var requests []*docs.Request
opDesc := ""
if ref.rowOp != "" {
numRows := len(table.TableRows)
switch ref.rowOp {
case opDelete:
target := ref.opTarget
if target < 0 {
target = numRows + target + 1
}
if target < 1 || target > numRows {
return fmt.Errorf("row %d out of range (table has %d rows)", ref.opTarget, numRows)
}
if numRows <= 1 {
return fmt.Errorf("cannot delete the only row in a table")
}
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: int64(target - 1),
ColumnIndex: 0,
}
requests = append(requests, &docs.Request{
DeleteTableRow: &docs.DeleteTableRowRequest{
TableCellLocation: cellLoc,
},
})
opDesc = fmt.Sprintf("deleted row %d", target)
case opInsert:
target := ref.opTarget
if target < 0 {
target = numRows + target + 1
}
if target < 1 || target > numRows {
return fmt.Errorf("row %d out of range for insert (table has %d rows)", ref.opTarget, numRows)
}
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: int64(target - 1),
ColumnIndex: 0,
}
requests = append(requests, &docs.Request{
InsertTableRow: &docs.InsertTableRowRequest{
TableCellLocation: cellLoc,
InsertBelow: false,
},
})
opDesc = fmt.Sprintf("inserted row before row %d", target)
case opAppend:
lastRow := numRows - 1
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: int64(lastRow),
ColumnIndex: 0,
}
requests = append(requests, &docs.Request{
InsertTableRow: &docs.InsertTableRowRequest{
TableCellLocation: cellLoc,
InsertBelow: true,
},
})
opDesc = "appended row at end"
}
}
if ref.colOp != "" {
numCols := 0
if len(table.TableRows) > 0 {
numCols = len(table.TableRows[0].TableCells)
}
switch ref.colOp {
case opDelete:
target := ref.opTarget
if target < 0 {
target = numCols + target + 1
}
if target < 1 || target > numCols {
return fmt.Errorf("col %d out of range (table has %d columns)", ref.opTarget, numCols)
}
if numCols <= 1 {
return fmt.Errorf("cannot delete the only column in a table")
}
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: 0,
ColumnIndex: int64(target - 1),
}
requests = append(requests, &docs.Request{
DeleteTableColumn: &docs.DeleteTableColumnRequest{
TableCellLocation: cellLoc,
},
})
opDesc = fmt.Sprintf("deleted column %d", target)
case opInsert:
target := ref.opTarget
if target < 0 {
target = numCols + target + 1
}
if target < 1 || target > numCols {
return fmt.Errorf("col %d out of range for insert (table has %d columns)", ref.opTarget, numCols)
}
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: 0,
ColumnIndex: int64(target - 1),
}
requests = append(requests, &docs.Request{
InsertTableColumn: &docs.InsertTableColumnRequest{
TableCellLocation: cellLoc,
InsertRight: false,
},
})
opDesc = fmt.Sprintf("inserted column before column %d", target)
case opAppend:
lastCol := numCols - 1
cellLoc := &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: 0,
ColumnIndex: int64(lastCol),
}
requests = append(requests, &docs.Request{
InsertTableColumn: &docs.InsertTableColumnRequest{
TableCellLocation: cellLoc,
InsertRight: true,
},
})
opDesc = "appended column at end"
}
}
if len(requests) == 0 {
return fmt.Errorf("no row/column operation to perform")
}
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (row/col op): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{"op", opDesc})
}
// runTableMerge handles merging or unmerging table cells.
// Merge: s/|1|[1,1:2,3]/merge/ — merges cells from [1,1] to [2,3]
// Unmerge/split: s/|1|[1,1]/unmerge/ or s/|1|[1,1]/split/
func (c *DocsSedCmd) runTableMerge(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
ref := expr.cellRef
docsSvc, doc, err := fetchDoc(ctx, account, id)
if err != nil {
return err
}
tablesWithIdx := collectAllTablesWithIndex(doc)
tIdx := ref.tableIndex
if tIdx < 0 {
tIdx = len(tablesWithIdx) + tIdx + 1
}
if tIdx < 1 || tIdx > len(tablesWithIdx) {
return fmt.Errorf("table index %d out of range (have %d tables)", ref.tableIndex, len(tablesWithIdx))
}
tableStartLoc := &docs.Location{Index: tablesWithIdx[tIdx-1].startIdx}
repl := strings.TrimSpace(strings.ToLower(expr.replacement))
var requests []*docs.Request
var opDesc string
switch repl {
case "merge":
if ref.endRow == 0 || ref.endCol == 0 {
return fmt.Errorf("merge requires a range: |N|[r1,c1:r2,c2]")
}
requests = append(requests, &docs.Request{
MergeTableCells: &docs.MergeTableCellsRequest{
TableRange: &docs.TableRange{
TableCellLocation: &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: int64(ref.row - 1),
ColumnIndex: int64(ref.col - 1),
},
RowSpan: int64(ref.endRow - ref.row + 1),
ColumnSpan: int64(ref.endCol - ref.col + 1),
},
},
})
opDesc = fmt.Sprintf("merged [%d,%d:%d,%d]", ref.row, ref.col, ref.endRow, ref.endCol)
case unmergeOp, splitOp:
requests = append(requests, &docs.Request{
UnmergeTableCells: &docs.UnmergeTableCellsRequest{
TableRange: &docs.TableRange{
TableCellLocation: &docs.TableCellLocation{
TableStartLocation: tableStartLoc,
RowIndex: int64(ref.row - 1),
ColumnIndex: int64(ref.col - 1),
},
// For unmerge, span the minimum (1x1) — Google will unmerge whatever
// merged region contains this cell
RowSpan: 1,
ColumnSpan: 1,
},
},
})
opDesc = fmt.Sprintf("unmerged [%d,%d]", ref.row, ref.col)
default:
return fmt.Errorf("unknown merge operation %q (expected merge, unmerge, or split)", repl)
}
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (%s): %w", opDesc, err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{"action", opDesc})
}

View File

@ -0,0 +1,429 @@
package cmd
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"google.golang.org/api/docs/v1"
"github.com/steipete/gogcli/internal/ui"
)
type tableCellRef struct {
tableIndex int // 1-indexed, negative means from end (-1 = last)
row int // 1-indexed, 0 means wildcard (*)
col int // 1-indexed, 0 means wildcard (*)
subPattern string // optional pattern to match within the cell
// Row/column operations
rowOp string // "delete", "insert" — set when [row:N] or [row:+N] syntax used
colOp string // "delete", "insert" — set when [col:N] or [col:+N] syntax used
opTarget int // target row/col index (1-indexed, negative from end)
// Merge range: [r1,c1:r2,c2] → merge cells from (row,col) to (endRow,endCol)
endRow int // 0 means no merge range
endCol int
}
// parseTableCellRef parses a table cell reference like |1|[2,3] or |1|[A1] or |-1|[1,1]
// Returns nil if the string is not a table cell reference.
// Optionally parses a trailing :pattern for within-cell matching.
func parseTableCellRef(s string) *tableCellRef {
// Must start with |
if len(s) == 0 || s[0] != '|' {
return nil
}
// Find second |
idx := strings.Index(s[1:], "|")
if idx < 0 {
return nil
}
tableStr := s[1 : 1+idx]
rest := s[1+idx+1:] // after second |
tableIdx, err := strconv.Atoi(tableStr)
if err != nil {
return nil
}
// Must have [...]
if len(rest) == 0 || rest[0] != '[' {
return nil
}
bracketEnd := strings.Index(rest, "]")
if bracketEnd < 0 {
return nil
}
cellStr := rest[1:bracketEnd]
after := rest[bracketEnd+1:]
// Check for row/col operations: [row:N], [row:+N], [col:N], [col:+N]
if strings.HasPrefix(cellStr, "row:") || strings.HasPrefix(cellStr, "col:") {
isRow := strings.HasPrefix(cellStr, "row:")
valStr := cellStr[4:]
ref := &tableCellRef{tableIndex: tableIdx}
if strings.HasPrefix(valStr, "+") {
// Insert operation
n, err := strconv.Atoi(valStr[1:])
if err != nil {
return nil
}
if isRow {
ref.rowOp = opInsert
ref.opTarget = n
} else {
ref.colOp = opInsert
ref.opTarget = n
}
} else {
// Delete operation (or special like $+ for append)
if valStr == "$+" {
// Append at end
if isRow {
ref.rowOp = opAppend
} else {
ref.colOp = opAppend
}
} else {
n, err := strconv.Atoi(valStr)
if err != nil {
return nil
}
if isRow {
ref.rowOp = opDelete
ref.opTarget = n
} else {
ref.colOp = opDelete
ref.opTarget = n
}
}
}
return ref
}
var row, col int
var endRow, endCol int
// Check for merge range syntax: R1,C1:R2,C2
if colonIdx := strings.Index(cellStr, ":"); colonIdx > 0 {
startPart := cellStr[:colonIdx]
endPart := cellStr[colonIdx+1:]
// Parse start cell
startParts := strings.SplitN(startPart, ",", 2)
if len(startParts) != 2 {
return nil
}
r, err := strconv.Atoi(strings.TrimSpace(startParts[0]))
if err != nil {
return nil
}
c, err2 := strconv.Atoi(strings.TrimSpace(startParts[1]))
if err2 != nil {
return nil
}
row, col = r, c
// Parse end cell
endParts := strings.SplitN(endPart, ",", 2)
if len(endParts) != 2 {
return nil
}
er, err3 := strconv.Atoi(strings.TrimSpace(endParts[0]))
if err3 != nil {
return nil
}
ec, err4 := strconv.Atoi(strings.TrimSpace(endParts[1]))
if err4 != nil {
return nil
}
endRow, endCol = er, ec
} else if parts := strings.SplitN(cellStr, ",", 2); len(parts) == 2 {
// Try R,C format (with wildcard support)
rStr := strings.TrimSpace(parts[0])
cStr := strings.TrimSpace(parts[1])
switch {
case rStr == "*":
row = 0 // wildcard
case strings.HasPrefix(rStr, "+"):
// +N means append row
ref := &tableCellRef{tableIndex: tableIdx, rowOp: "append"}
n, err := strconv.Atoi(rStr[1:])
if err == nil {
ref.opTarget = n
}
// Parse col for the append target
if cStr == "*" {
ref.col = 0
} else {
c, err := strconv.Atoi(cStr)
if err != nil {
return nil
}
ref.col = c
}
return ref
default:
r, err := strconv.Atoi(rStr)
if err != nil {
return nil
}
row = r
}
switch {
case cStr == "*":
col = 0 // wildcard
case strings.HasPrefix(cStr, "+"):
// +N means append column
ref := &tableCellRef{tableIndex: tableIdx, colOp: "append"}
n, err := strconv.Atoi(cStr[1:])
if err == nil {
ref.opTarget = n
}
ref.row = row
return ref
default:
c, err := strconv.Atoi(cStr)
if err != nil {
return nil
}
col = c
}
} else {
// Try Excel-style A1
r, c, ok := parseExcelRef(cellStr)
if !ok {
return nil
}
row, col = r, c
}
ref := &tableCellRef{tableIndex: tableIdx, row: row, col: col, endRow: endRow, endCol: endCol}
// Optional :pattern
if strings.HasPrefix(after, ":") {
ref.subPattern = after[1:]
}
return ref
}
// parseExcelRef parses an Excel-style cell reference like "A1", "B2", "AA10"
// Returns (row, col, ok) where row and col are 1-indexed.
func parseExcelRef(s string) (row, col int, ok bool) {
s = strings.TrimSpace(s)
if len(s) == 0 {
return 0, 0, false
}
// Split into letter part and number part
i := 0
for i < len(s) && ((s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z')) {
i++
}
if i == 0 || i == len(s) {
return 0, 0, false
}
letters := strings.ToUpper(s[:i])
numStr := s[i:]
r, err := strconv.Atoi(numStr)
if err != nil || r < 1 {
return 0, 0, false
}
// Convert letters to column number
c := 0
for _, ch := range letters {
c = c*26 + int(ch-'A') + 1
}
return r, c, true
}
func (c *DocsSedCmd) runTableOp(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
}
var doc *docs.Document
err = retryOnQuota(ctx, func() error {
var e error
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("get document: %w", err)
}
// Collect all tables with their structural element indices
type tableInfo struct {
table *docs.Table
startIdx int64
endIdx int64
}
var tables []tableInfo
if doc.Body != nil {
for _, elem := range doc.Body.Content {
if elem.Table != nil {
tables = append(tables, tableInfo{
table: elem.Table,
startIdx: elem.StartIndex,
endIdx: elem.EndIndex,
})
}
}
}
if len(tables) == 0 {
return fmt.Errorf("document has no tables")
}
// Resolve which tables to target
var targets []tableInfo
tIdx := expr.tableRef
if tIdx == math.MinInt32 {
// |*| — all tables
targets = tables
} else {
resolved := tIdx
if resolved < 0 {
resolved = len(tables) + resolved + 1
}
if resolved < 1 || resolved > len(tables) {
return fmt.Errorf("table %d out of range (document has %d tables)", tIdx, len(tables))
}
targets = []tableInfo{tables[resolved-1]}
}
// Handle the operation based on replacement
replacement := strings.TrimSpace(expr.replacement)
if replacement == "" {
// DELETE tables — process in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
t := targets[i]
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: t.startIdx,
EndIndex: t.endIdx,
},
},
})
}
err = retryOnQuota(ctx, func() error {
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
return e
})
if err != nil {
return fmt.Errorf("batch update (delete table): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{"deleted", fmt.Sprintf("%d table(s)", len(targets))})
}
// Future: handle pin=N, other table-level operations
return fmt.Errorf("unsupported table operation: %q (expected empty replacement for delete)", replacement)
}
type tableWithIndex struct {
table *docs.Table
startIdx int64
}
// collectAllTablesWithIndex returns all tables in the document along with their
// structural element index, used for operations that need positional context.
func collectAllTablesWithIndex(doc *docs.Document) []tableWithIndex {
var tables []tableWithIndex
var walkContent func(content []*docs.StructuralElement)
walkContent = func(content []*docs.StructuralElement) {
for _, elem := range content {
if elem.Table != nil {
tables = append(tables, tableWithIndex{table: elem.Table, startIdx: elem.StartIndex})
for _, row := range elem.Table.TableRows {
for _, cell := range row.TableCells {
walkContent(cell.Content)
}
}
}
}
}
if doc.Body != nil {
walkContent(doc.Body.Content)
}
return tables
}
// collectAllTables returns all tables in the document in order of appearance.
func collectAllTables(doc *docs.Document) []*docs.Table {
withIdx := collectAllTablesWithIndex(doc)
tables := make([]*docs.Table, len(withIdx))
for i, t := range withIdx {
tables[i] = t.table
}
return tables
}
// findTableCell locates a specific table cell in the document.
// Tables are numbered in document order including nested tables.
func findTableCell(doc *docs.Document, ref *tableCellRef) (*docs.TableCell, error) {
tables := collectAllTables(doc)
if len(tables) == 0 {
return nil, fmt.Errorf("document has no tables")
}
// Resolve table index
ti := ref.tableIndex
if ti < 0 {
ti = len(tables) + ti + 1 // -1 → last
}
if ti < 1 || ti > len(tables) {
return nil, fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tables))
}
table := tables[ti-1]
// Resolve row
if ref.row < 1 || ref.row > len(table.TableRows) {
return nil, fmt.Errorf("row %d out of range (table has %d rows)", ref.row, len(table.TableRows))
}
row := table.TableRows[ref.row-1]
// Resolve col
if ref.col < 1 || ref.col > len(row.TableCells) {
return nil, fmt.Errorf("col %d out of range (row has %d columns)", ref.col, len(row.TableCells))
}
return row.TableCells[ref.col-1], nil
}
// getCellText extracts the plain text content from a table cell.
// Returns the concatenated text, the start index of the first text run,
// and the end index of the last text run.
func getCellText(cell *docs.TableCell) (text string, startIdx int64, endIdx int64) {
var b strings.Builder
for _, elem := range cell.Content {
if elem.Paragraph != nil {
for _, pe := range elem.Paragraph.Elements {
if pe.TextRun != nil {
b.WriteString(pe.TextRun.Content)
if startIdx == 0 && pe.StartIndex > 0 {
startIdx = pe.StartIndex
}
endIdx = pe.EndIndex
}
}
}
}
return b.String(), startIdx, endIdx
}
// runTableCellReplace handles sed expressions targeting specific table cells