diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..805e337 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +GOG_TEST_ACCOUNT=your-google-account@example.com +GOG_TEST_DOC_ID=your-test-document-id diff --git a/go.mod b/go.mod index b32140a..d4a3d70 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index cf0fb7a..661019e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 12aab41..991e8e0 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -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 diff --git a/internal/cmd/contacts_crud.go b/internal/cmd/contacts_crud.go index 31cdc29..b5dfd6b 100644 --- a/internal/cmd/contacts_crud.go +++ b/internal/cmd/contacts_crud.go @@ -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 diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 6860cd2..f1b2cc8 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -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 '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"` diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go new file mode 100644 index 0000000..251798f --- /dev/null +++ b/internal/cmd/docs_edit.go @@ -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 +} diff --git a/internal/cmd/docs_markdown.go b/internal/cmd/docs_markdown.go index 18c07a0..09eefa5 100644 --- a/internal/cmd/docs_markdown.go +++ b/internal/cmd/docs_markdown.go @@ -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, }) diff --git a/internal/cmd/docs_sed.go b/internal/cmd/docs_sed.go new file mode 100644 index 0000000..82646db --- /dev/null +++ b/internal/cmd/docs_sed.go @@ -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}) +} diff --git a/internal/cmd/docs_sed_brace.go b/internal/cmd/docs_sed_brace.go new file mode 100644 index 0000000..7b355c2 --- /dev/null +++ b/internal/cmd/docs_sed_brace.go @@ -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 + } +} diff --git a/internal/cmd/docs_sed_brace_format.go b/internal/cmd/docs_sed_brace_format.go new file mode 100644 index 0000000..43713a4 --- /dev/null +++ b/internal/cmd/docs_sed_brace_format.go @@ -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) diff --git a/internal/cmd/docs_sed_brace_match.go b/internal/cmd/docs_sed_brace_match.go new file mode 100644 index 0000000..6a28e3c --- /dev/null +++ b/internal/cmd/docs_sed_brace_match.go @@ -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 +} diff --git a/internal/cmd/docs_sed_brace_pattern.go b/internal/cmd/docs_sed_brace_pattern.go new file mode 100644 index 0000000..cfcb6b7 --- /dev/null +++ b/internal/cmd/docs_sed_brace_pattern.go @@ -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 "" + } + 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 "" + } + 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=?}" +} diff --git a/internal/cmd/docs_sed_brace_resolve.go b/internal/cmd/docs_sed_brace_resolve.go new file mode 100644 index 0000000..7b90ba9 --- /dev/null +++ b/internal/cmd/docs_sed_brace_resolve.go @@ -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 +} diff --git a/internal/cmd/docs_sed_brace_structural.go b/internal/cmd/docs_sed_brace_structural.go new file mode 100644 index 0000000..db8d9fc --- /dev/null +++ b/internal/cmd/docs_sed_brace_structural.go @@ -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. diff --git a/internal/cmd/docs_sed_commands.go b/internal/cmd/docs_sed_commands.go new file mode 100644 index 0000000..fb4df2d --- /dev/null +++ b/internal/cmd/docs_sed_commands.go @@ -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") +} diff --git a/internal/cmd/docs_sed_dryrun.go b/internal/cmd/docs_sed_dryrun.go new file mode 100644 index 0000000..a55db9f --- /dev/null +++ b/internal/cmd/docs_sed_dryrun.go @@ -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] + "..." +} diff --git a/internal/cmd/docs_sed_helpers.go b/internal/cmd/docs_sed_helpers.go new file mode 100644 index 0000000..623016c --- /dev/null +++ b/internal/cmd/docs_sed_helpers.go @@ -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 +} diff --git a/internal/cmd/docs_sed_images.go b/internal/cmd/docs_sed_images.go new file mode 100644 index 0000000..d768532 --- /dev/null +++ b/internal/cmd/docs_sed_images.go @@ -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 +} diff --git a/internal/cmd/docs_sed_insert.go b/internal/cmd/docs_sed_insert.go new file mode 100644 index 0000000..91e3bb0 --- /dev/null +++ b/internal/cmd/docs_sed_insert.go @@ -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}) +} diff --git a/internal/cmd/docs_sed_manual.go b/internal/cmd/docs_sed_manual.go new file mode 100644 index 0000000..eebc28b --- /dev/null +++ b/internal/cmd/docs_sed_manual.go @@ -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 +} diff --git a/internal/cmd/docs_sed_nesting.go b/internal/cmd/docs_sed_nesting.go new file mode 100644 index 0000000..5ffd795 --- /dev/null +++ b/internal/cmd/docs_sed_nesting.go @@ -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 +} diff --git a/internal/cmd/docs_sed_parse.go b/internal/cmd/docs_sed_parse.go new file mode 100644 index 0000000..917cb26 --- /dev/null +++ b/internal/cmd/docs_sed_parse.go @@ -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) diff --git a/internal/cmd/docs_sed_retry.go b/internal/cmd/docs_sed_retry.go new file mode 100644 index 0000000..e225665 --- /dev/null +++ b/internal/cmd/docs_sed_retry.go @@ -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< 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") +} diff --git a/internal/cmd/docs_sed_table_cells.go b/internal/cmd/docs_sed_table_cells.go new file mode 100644 index 0000000..57e4000 --- /dev/null +++ b/internal/cmd/docs_sed_table_cells.go @@ -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}) +} diff --git a/internal/cmd/docs_sed_table_create.go b/internal/cmd/docs_sed_table_create.go new file mode 100644 index 0000000..11faf58 --- /dev/null +++ b/internal/cmd/docs_sed_table_create.go @@ -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...) +} diff --git a/internal/cmd/docs_sed_table_ops.go b/internal/cmd/docs_sed_table_ops.go new file mode 100644 index 0000000..d140b86 --- /dev/null +++ b/internal/cmd/docs_sed_table_ops.go @@ -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}) +} diff --git a/internal/cmd/docs_sed_tables.go b/internal/cmd/docs_sed_tables.go new file mode 100644 index 0000000..dc2f7a7 --- /dev/null +++ b/internal/cmd/docs_sed_tables.go @@ -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