feat(docs): add sedmat — sed-like document formatting DSL
This commit is contained in:
parent
4ac03838c9
commit
e2d41ad87c
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
GOG_TEST_ACCOUNT=your-google-account@example.com
|
||||
GOG_TEST_DOC_ID=your-test-document-id
|
||||
6
go.mod
6
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
|
||||
)
|
||||
|
||||
1
go.sum
1
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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -34,6 +35,9 @@ type DocsCmd struct {
|
||||
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
|
||||
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"`
|
||||
Update DocsUpdateCmd `cmd:"" name:"update" help:"Update content in a Google Doc"`
|
||||
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
|
||||
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
|
||||
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
|
||||
}
|
||||
type DocsExportCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
@ -271,6 +275,7 @@ type DocsCatCmd struct {
|
||||
MaxBytes int64 `name:"max-bytes" help:"Max bytes to read (0 = unlimited)" default:"2000000"`
|
||||
Tab string `name:"tab" help:"Tab title or ID to read (omit for default behavior)"`
|
||||
AllTabs bool `name:"all-tabs" help:"Show all tabs with headers"`
|
||||
Raw bool `name:"raw" help:"Output the raw Google Docs API JSON response without modifications"`
|
||||
}
|
||||
|
||||
func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
@ -289,6 +294,33 @@ func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// --raw: dump the full Google Docs API response as JSON.
|
||||
if c.Raw {
|
||||
call := svc.Documents.Get(id).Context(ctx)
|
||||
if c.Tab != "" || c.AllTabs {
|
||||
call = call.IncludeTabsContent(true)
|
||||
}
|
||||
doc, rawErr := call.Do()
|
||||
if rawErr != nil {
|
||||
if isDocsNotFound(rawErr) {
|
||||
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
|
||||
}
|
||||
return rawErr
|
||||
}
|
||||
raw, rawErr := doc.MarshalJSON()
|
||||
if rawErr != nil {
|
||||
return fmt.Errorf("marshalling raw response: %w", rawErr)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if indentErr := json.Indent(&buf, raw, "", " "); indentErr != nil {
|
||||
_, werr := os.Stdout.Write(raw)
|
||||
return werr
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
_, rawErr = buf.WriteTo(os.Stdout)
|
||||
return rawErr
|
||||
}
|
||||
|
||||
// Use tabs API when --tab or --all-tabs is specified.
|
||||
if c.Tab != "" || c.AllTabs {
|
||||
return c.runWithTabs(ctx, svc, id)
|
||||
@ -886,6 +918,25 @@ func (c *DocsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DocsClearCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
}
|
||||
|
||||
func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
// Clear delegates to: gog docs sed <docId> 's/^$//'
|
||||
// s/^$// with empty replacement on a non-empty doc = clear all content.
|
||||
docID := strings.TrimSpace(c.DocID)
|
||||
if docID == "" {
|
||||
return usage("empty docId")
|
||||
}
|
||||
|
||||
sedCmd := DocsSedCmd{
|
||||
DocID: docID,
|
||||
Expression: `s/^$//`,
|
||||
}
|
||||
return sedCmd.Run(ctx, flags)
|
||||
}
|
||||
|
||||
type DocsFindReplaceCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
Find string `arg:"" name:"find" help:"Text to find"`
|
||||
|
||||
84
internal/cmd/docs_edit.go
Normal file
84
internal/cmd/docs_edit.go
Normal file
@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// DocsEditCmd does find/replace in a Google Doc
|
||||
type DocsEditCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
Find string `name:"find" short:"f" help:"Text to find" required:""`
|
||||
ReplaceStr string `name:"replace" short:"r" help:"Text to replace with" required:""`
|
||||
MatchCase bool `name:"match-case" help:"Case-sensitive matching" default:"true"`
|
||||
}
|
||||
|
||||
func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(c.DocID)
|
||||
if id == "" {
|
||||
return usage("empty docId")
|
||||
}
|
||||
|
||||
if c.Find == "" {
|
||||
return usage("empty find text")
|
||||
}
|
||||
|
||||
// Create Docs service
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
// Build replace request
|
||||
requests := []*docs.Request{
|
||||
{
|
||||
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
||||
ContainsText: &docs.SubstringMatchCriteria{
|
||||
Text: c.Find,
|
||||
MatchCase: c.MatchCase,
|
||||
},
|
||||
ReplaceText: c.ReplaceStr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Execute batch update
|
||||
resp, err := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update document: %w", err)
|
||||
}
|
||||
|
||||
// Get count of replacements
|
||||
replaced := int64(0)
|
||||
if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil {
|
||||
replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"status": "ok",
|
||||
"docId": id,
|
||||
"replaced": replaced,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("status\tok")
|
||||
u.Out().Printf("docId\t%s", id)
|
||||
u.Out().Printf("replaced\t%d", replaced)
|
||||
return nil
|
||||
}
|
||||
@ -7,6 +7,11 @@ import (
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const (
|
||||
fmtBold = "bold"
|
||||
fmtBoldItalic = "bolditalic"
|
||||
)
|
||||
|
||||
// MarkdownElementType represents the type of markdown element
|
||||
type MarkdownElementType int
|
||||
|
||||
@ -348,7 +353,7 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
|
||||
Start: idx[0],
|
||||
End: idx[1],
|
||||
Content: text[idx[2]:idx[3]],
|
||||
Type: "bold",
|
||||
Type: fmtBold,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -356,7 +361,7 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
|
||||
// For italic, we need to be careful not to match asterisks that are part of bold
|
||||
boldPositions := make(map[int]bool)
|
||||
for _, m := range matches {
|
||||
if m.Type == "bold" || m.Type == "bolditalic" {
|
||||
if m.Type == fmtBold || m.Type == fmtBoldItalic {
|
||||
for i := m.Start; i <= m.End; i++ {
|
||||
boldPositions[i] = true
|
||||
}
|
||||
@ -440,8 +445,8 @@ func ParseInlineFormatting(text string) ([]TextStyle, string) {
|
||||
styles = append(styles, TextStyle{
|
||||
Start: positionMap[m.Start],
|
||||
End: positionMap[m.End],
|
||||
Bold: m.Type == "bold" || m.Type == "bolditalic",
|
||||
Italic: m.Type == "italic" || m.Type == "bolditalic",
|
||||
Bold: m.Type == fmtBold || m.Type == fmtBoldItalic,
|
||||
Italic: m.Type == "italic" || m.Type == fmtBoldItalic,
|
||||
Code: m.Type == inlineTypeCode,
|
||||
Link: m.URL,
|
||||
})
|
||||
|
||||
638
internal/cmd/docs_sed.go
Normal file
638
internal/cmd/docs_sed.go
Normal file
@ -0,0 +1,638 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// DocsSedCmd implements sed-like find-and-replace operations on Google Docs.
|
||||
// It supports text replacement, regex, table operations, image insertion, and formatting.
|
||||
type DocsSedCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
Expression string `arg:"" optional:"" name:"expression" help:"sed expression: s/pattern/replacement/flags"`
|
||||
Expressions []string `short:"e" help:"Additional sed expressions (repeatable)"`
|
||||
File string `short:"f" help:"Read sed expressions from file (one per line, # comments)"`
|
||||
}
|
||||
|
||||
// parseExpressionLines splits data into trimmed non-empty, non-comment lines.
|
||||
func parseExpressionLines(data []byte) []string {
|
||||
var exprs []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
exprs = append(exprs, line)
|
||||
}
|
||||
return exprs
|
||||
}
|
||||
|
||||
// collectExpressions gathers sed expressions from positional arg, -e flags, -f file, and stdin.
|
||||
func (c *DocsSedCmd) collectExpressions() ([]string, error) {
|
||||
var exprs []string
|
||||
|
||||
// 1. Positional argument
|
||||
if c.Expression != "" {
|
||||
exprs = append(exprs, c.Expression)
|
||||
}
|
||||
|
||||
// 2. -e flags
|
||||
exprs = append(exprs, c.Expressions...)
|
||||
|
||||
// 3. -f file
|
||||
if c.File != "" {
|
||||
data, err := os.ReadFile(c.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read sed file: %w", err)
|
||||
}
|
||||
exprs = append(exprs, parseExpressionLines(data)...)
|
||||
}
|
||||
|
||||
// 4. Stdin (only if no expressions from other sources and stdin is not a terminal)
|
||||
if len(exprs) == 0 {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
exprs = append(exprs, parseExpressionLines(data)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(exprs) == 0 {
|
||||
return nil, usage("no sed expressions provided (use positional arg, -e, -f, or stdin)")
|
||||
}
|
||||
|
||||
return exprs, nil
|
||||
}
|
||||
|
||||
type sedExpr struct {
|
||||
pattern string
|
||||
replacement string // escaped for Go's regexp.ReplaceAllString ($$ = literal $, ${N} = backref)
|
||||
global bool
|
||||
nthMatch int // >0 means replace only the Nth occurrence (e.g., s/foo/bar/2)
|
||||
cellRef *tableCellRef // non-nil if targeting a specific table cell
|
||||
tableRef int // non-zero if targeting a whole table (1-indexed, negative from end; math.MinInt32 = all)
|
||||
command byte // 0 for s//, 'd' for delete, 'a' for append, 'i' for insert, 'y' for transliterate
|
||||
brace *braceExpr // optional brace expression for SEDMAT v3.5 syntax
|
||||
braceSpans []*braceSpan // positioned brace spans for inline scoping
|
||||
}
|
||||
|
||||
type indexedExpr struct {
|
||||
index int
|
||||
expr sedExpr
|
||||
}
|
||||
|
||||
// literalReplacement returns the replacement string with Go regex escaping undone,
|
||||
// suitable for direct text insertion (not through regexp.ReplaceAllString).
|
||||
func literalReplacement(repl string) string {
|
||||
// $$ → $ (Go regex literal dollar)
|
||||
result := strings.ReplaceAll(repl, "$$", "$")
|
||||
// Remove ${0} (whole match backref has no meaning in direct insertion without context)
|
||||
// Table wildcard code handles & expansion separately before calling this.
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *DocsSedCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
|
||||
id := strings.TrimSpace(c.DocID)
|
||||
if id == "" {
|
||||
return usage("empty docId")
|
||||
}
|
||||
|
||||
// Collect all expressions
|
||||
rawExprs, err := c.collectExpressions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("collect expressions: %w", err)
|
||||
}
|
||||
|
||||
// Parse all expressions
|
||||
var parsed []sedExpr
|
||||
for i, raw := range rawExprs {
|
||||
expr, parseErr := parseFullExpr(raw)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("expression %d (%q): %w", i+1, raw, parseErr)
|
||||
}
|
||||
parsed = append(parsed, expr)
|
||||
}
|
||||
|
||||
if flags != nil && flags.DryRun {
|
||||
return c.runDryRun(ctx, u, parsed)
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("require account: %w", err)
|
||||
}
|
||||
|
||||
// Single expression: use optimized paths (native, image ref, etc.)
|
||||
if len(parsed) == 1 {
|
||||
return c.runSingle(ctx, u, account, id, parsed[0])
|
||||
}
|
||||
|
||||
// Multiple expressions: batch them
|
||||
return c.runBatch(ctx, u, account, id, parsed)
|
||||
}
|
||||
|
||||
// runPositionalInsert handles ^, $, and ^$ patterns for prepend, append, and empty-doc insert.
|
||||
func (c *DocsSedCmd) runPositionalInsert(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) (bool, error) {
|
||||
if expr.pattern != "^$" && expr.pattern != "^" && expr.pattern != "$" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
var doc *docs.Document
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
|
||||
// Find document body end index
|
||||
var bodyEnd int64
|
||||
if doc.Body != nil && len(doc.Body.Content) > 0 {
|
||||
last := doc.Body.Content[len(doc.Body.Content)-1]
|
||||
bodyEnd = last.EndIndex
|
||||
}
|
||||
if bodyEnd < 2 {
|
||||
bodyEnd = 2 // minimum: index 1 is start, trailing \n at end
|
||||
}
|
||||
|
||||
// Determine if document is empty (only whitespace/newlines)
|
||||
isEmpty := true
|
||||
if doc.Body != nil {
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.Paragraph != nil {
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun != nil && strings.TrimSpace(pe.TextRun.Content) != "" {
|
||||
isEmpty = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if elem.Table != nil {
|
||||
isEmpty = false
|
||||
}
|
||||
if !isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch expr.pattern {
|
||||
case "^$":
|
||||
if expr.replacement == "" && !isEmpty {
|
||||
// s/^$// on a non-empty doc = clear all content
|
||||
deleteEnd := bodyEnd - 1
|
||||
if deleteEnd < 2 {
|
||||
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", 0})
|
||||
}
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: []*docs.Request{{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: 1,
|
||||
EndIndex: deleteEnd,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("clearing document: %w", err)
|
||||
}
|
||||
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", deleteEnd - 1})
|
||||
}
|
||||
if !isEmpty {
|
||||
// Not empty — no match, report 0 replaced
|
||||
return true, sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "document is not empty"})
|
||||
}
|
||||
// Empty doc with empty replacement = no-op (already empty)
|
||||
if expr.replacement == "" {
|
||||
return true, sedOutputOK(ctx, u, id, sedOutputKV{"cleared", 0})
|
||||
}
|
||||
// Empty doc — insert at index 1
|
||||
return true, c.doPositionalInsert(ctx, docsSvc, u, id, 1, literalReplacement(expr.replacement))
|
||||
|
||||
case "^":
|
||||
// Prepend — insert at index 1 (beginning of body)
|
||||
return true, c.doPositionalInsert(ctx, docsSvc, u, id, 1, literalReplacement(expr.replacement))
|
||||
|
||||
case "$":
|
||||
// Append — insert before the final newline
|
||||
insertIdx := bodyEnd - 1
|
||||
if insertIdx < 1 {
|
||||
insertIdx = 1
|
||||
}
|
||||
return true, c.doPositionalInsert(ctx, docsSvc, u, id, insertIdx, literalReplacement(expr.replacement))
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// runSingle executes a single sed expression, routing to the appropriate handler
|
||||
// based on the expression type (command, table, positional, cell, image, native, or manual).
|
||||
func (c *DocsSedCmd) runSingle(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
// Handle non-substitution commands
|
||||
switch expr.command {
|
||||
case 'd':
|
||||
return c.runDeleteCommand(ctx, u, account, id, expr)
|
||||
case 'a':
|
||||
return c.runAppendCommand(ctx, u, account, id, expr)
|
||||
case 'i':
|
||||
return c.runInsertCommand(ctx, u, account, id, expr)
|
||||
case 'y':
|
||||
return c.runTransliterate(ctx, u, account, id, expr)
|
||||
}
|
||||
|
||||
// Check table-level operations (s/|1|//, s/|*|//, etc.)
|
||||
if expr.tableRef != 0 {
|
||||
return c.runTableOp(ctx, u, account, id, expr)
|
||||
}
|
||||
|
||||
// Check positional patterns first: ^$ (empty), ^ (prepend), $ (append)
|
||||
if handled, err := c.runPositionalInsert(ctx, u, account, id, expr); handled {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this targets a specific table cell
|
||||
if expr.cellRef != nil {
|
||||
// Check for merge/split operations
|
||||
repl := strings.TrimSpace(strings.ToLower(expr.replacement))
|
||||
if repl == mergeOp || repl == unmergeOp || repl == splitOp {
|
||||
return c.runTableMerge(ctx, u, account, id, expr)
|
||||
}
|
||||
return c.runTableCellReplace(ctx, u, account, id, expr)
|
||||
}
|
||||
|
||||
// Check if replacement is a table creation spec (|RxC|, |RxC:header|, or pipe-table)
|
||||
if tableSpec := parseTableCreate(expr.replacement); tableSpec != nil {
|
||||
return c.runTableCreate(ctx, u, account, id, expr, tableSpec)
|
||||
}
|
||||
if tableSpec := parseTableFromPipes(expr.replacement); tableSpec != nil {
|
||||
return c.runTableCreate(ctx, u, account, id, expr, tableSpec)
|
||||
}
|
||||
|
||||
// Check if pattern is an image reference (!(n), ![regex], etc.)
|
||||
imgRef := parseImageRefPattern(expr.pattern)
|
||||
if imgRef != nil {
|
||||
return c.runImageReplace(ctx, u, account, id, imgRef, expr.replacement, expr.global)
|
||||
}
|
||||
|
||||
// Check if we can use native API (no formatting in replacement)
|
||||
if canUseNativeReplace(expr.replacement) && expr.global && expr.nthMatch <= 0 {
|
||||
return c.runNative(ctx, u, account, id, expr.pattern, expr.replacement)
|
||||
}
|
||||
|
||||
return c.runManual(ctx, u, account, id, expr)
|
||||
}
|
||||
|
||||
// runBatch executes multiple sed expressions efficiently by batching native replacements
|
||||
// into a single API call and routing other types to their specialized handlers.
|
||||
func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string, exprs []sedExpr) error {
|
||||
// Split into native (plain text replace) and manual (needs formatting/images)
|
||||
type indexedTableExpr struct {
|
||||
index int
|
||||
expr sedExpr
|
||||
spec *tableCreateSpec
|
||||
}
|
||||
var nativeExprs []indexedExpr
|
||||
var manualExprs []indexedExpr
|
||||
var cellExprs []indexedExpr
|
||||
var tableCreateExprs []indexedTableExpr
|
||||
|
||||
// Positional patterns (^$, ^, $) must run individually and sequentially
|
||||
// because each one changes the document state.
|
||||
var positionalExprs []indexedExpr
|
||||
// Image replacements must run individually — Google Docs API cannot
|
||||
// reliably fetch images when mixed with other batch operations.
|
||||
var imageExprs []indexedExpr
|
||||
|
||||
for i, expr := range exprs {
|
||||
ie := indexedExpr{i, expr}
|
||||
switch classifyExprForBatch(expr) {
|
||||
case exprCatPositional:
|
||||
positionalExprs = append(positionalExprs, ie)
|
||||
case exprCatImage:
|
||||
imageExprs = append(imageExprs, ie)
|
||||
case exprCatCommand, exprCatImagePattern:
|
||||
manualExprs = append(manualExprs, ie)
|
||||
case exprCatCell:
|
||||
cellExprs = append(cellExprs, ie)
|
||||
case exprCatTableCreate:
|
||||
spec := parseTableCreate(expr.replacement)
|
||||
if spec == nil {
|
||||
spec = parseTableFromPipes(expr.replacement)
|
||||
}
|
||||
tableCreateExprs = append(tableCreateExprs, indexedTableExpr{i, expr, spec})
|
||||
case exprCatNative:
|
||||
nativeExprs = append(nativeExprs, ie)
|
||||
case exprCatManual:
|
||||
manualExprs = append(manualExprs, ie)
|
||||
}
|
||||
}
|
||||
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
totalReplaced := 0
|
||||
|
||||
// Run positional expressions sequentially (each changes doc state)
|
||||
for _, ie := range positionalExprs {
|
||||
if _, err := c.runPositionalInsert(ctx, u, account, id, ie.expr); err != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch all native expressions into one API call
|
||||
if len(nativeExprs) > 0 {
|
||||
var requests []*docs.Request
|
||||
for _, ie := range nativeExprs {
|
||||
requests = append(requests, &docs.Request{
|
||||
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
||||
ContainsText: &docs.SubstringMatchCriteria{
|
||||
Text: ie.expr.pattern,
|
||||
MatchCase: true,
|
||||
SearchByRegex: true,
|
||||
},
|
||||
ReplaceText: ie.expr.replacement,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := batchUpdate(ctx, docsSvc, id, requests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("native batch update: %w", err)
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
for _, reply := range resp.Replies {
|
||||
if reply.ReplaceAllText != nil {
|
||||
totalReplaced += int(reply.ReplaceAllText.OccurrencesChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process manual expressions sequentially (each may need fresh doc state)
|
||||
for _, ie := range manualExprs {
|
||||
// Non-substitution commands (d/a/i/y) run through runSingle
|
||||
if ie.expr.command != 0 {
|
||||
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, singleErr)
|
||||
}
|
||||
totalReplaced++
|
||||
continue
|
||||
}
|
||||
|
||||
imgRef := parseImageRefPattern(ie.expr.pattern)
|
||||
if imgRef != nil {
|
||||
if imgErr := c.runImageReplace(ctx, u, account, id, imgRef, ie.expr.replacement, ie.expr.global); imgErr != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, imgErr)
|
||||
}
|
||||
totalReplaced++
|
||||
continue
|
||||
}
|
||||
|
||||
// Manual formatting replace — need to fetch doc each time since indices shift
|
||||
count, _, manualErr := c.runManualInner(ctx, docsSvc, id, ie.expr)
|
||||
if manualErr != nil {
|
||||
return fmt.Errorf("expression %d (pattern=%q repl=%q): %w", ie.index+1, ie.expr.pattern, ie.expr.replacement, manualErr)
|
||||
}
|
||||
totalReplaced += count
|
||||
}
|
||||
|
||||
// Apply deferred nested bullets. Re-fetches doc to find paragraphs with
|
||||
// leading \t, groups them with adjacent bulleted paragraphs, and re-creates
|
||||
// bullets with merged ranges so Docs interprets tabs as nesting levels.
|
||||
if len(manualExprs) > 0 {
|
||||
if bulletErr := c.applyDeferredBullets(ctx, docsSvc, id); bulletErr != nil {
|
||||
return fmt.Errorf("apply bullets: %w", bulletErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Process table creation expressions sequentially (each creates a table via API)
|
||||
for _, ie := range tableCreateExprs {
|
||||
if createErr := c.runTableCreate(ctx, u, account, id, ie.expr, ie.spec); createErr != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, createErr)
|
||||
}
|
||||
totalReplaced++
|
||||
}
|
||||
|
||||
// Process cell expressions — batch same-table whole-cell replacements
|
||||
cellReplaced, err := c.processCellExprs(ctx, u, account, id, cellExprs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalReplaced += cellReplaced
|
||||
|
||||
// Process image replacements individually (each needs its own API call for reliable image fetching).
|
||||
// Add a brief pause before each image to avoid rate-limit issues with Google's image fetcher.
|
||||
for _, ie := range imageExprs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
|
||||
// Retry once after a longer pause (image fetch can be flaky under load)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
if retryErr := c.runSingle(ctx, u, account, id, ie.expr); retryErr != nil {
|
||||
return fmt.Errorf("expression %d (image): %w", ie.index+1, retryErr)
|
||||
}
|
||||
}
|
||||
totalReplaced++
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"expressions", len(exprs)}, sedOutputKV{"replaced", totalReplaced})
|
||||
}
|
||||
|
||||
// exprCategory describes how an expression should be processed in batch mode.
|
||||
type exprCategory int
|
||||
|
||||
const (
|
||||
exprCatPositional exprCategory = iota // ^, $, ^$ — sequential, changes doc state
|
||||
exprCatImage // image replacement — individual API calls
|
||||
exprCatCommand // d/a/i/y — run individually via runSingle
|
||||
exprCatCell // table cell targeting
|
||||
exprCatTableCreate // table creation spec
|
||||
exprCatImagePattern // image pattern in search (!(n), ![re])
|
||||
exprCatNative // plain text replace via native API
|
||||
exprCatManual // requires manual formatting path
|
||||
)
|
||||
|
||||
// classifyExprForBatch determines how an expression should be processed in batch mode.
|
||||
func classifyExprForBatch(expr sedExpr) exprCategory {
|
||||
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
|
||||
(expr.pattern == "^$" || expr.pattern == "^" || expr.pattern == "$") {
|
||||
return exprCatPositional
|
||||
}
|
||||
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
|
||||
(strings.HasPrefix(expr.replacement, "![") ||
|
||||
(expr.brace != nil && expr.brace.ImgRef != "")) {
|
||||
return exprCatImage
|
||||
}
|
||||
if expr.command != 0 {
|
||||
return exprCatCommand
|
||||
}
|
||||
if expr.cellRef != nil || expr.tableRef != 0 {
|
||||
return exprCatCell
|
||||
}
|
||||
if parseTableCreate(expr.replacement) != nil || parseTableFromPipes(expr.replacement) != nil {
|
||||
return exprCatTableCreate
|
||||
}
|
||||
if parseImageRefPattern(expr.pattern) != nil {
|
||||
return exprCatImagePattern
|
||||
}
|
||||
if canUseNativeReplace(expr.replacement) && expr.global && expr.nthMatch <= 0 {
|
||||
return exprCatNative
|
||||
}
|
||||
return exprCatManual
|
||||
}
|
||||
|
||||
// isMergeOp returns true if the replacement string (lowered+trimmed) is a merge/unmerge/split op.
|
||||
func isMergeOp(repl string) bool {
|
||||
r := strings.TrimSpace(strings.ToLower(repl))
|
||||
return r == mergeOp || r == unmergeOp || r == splitOp
|
||||
}
|
||||
|
||||
// canBatchCell returns true if the expression is a simple whole-cell replacement
|
||||
// that can be batched with adjacent same-table expressions.
|
||||
func canBatchCell(ie indexedExpr) bool {
|
||||
return ie.expr.cellRef != nil && ie.expr.pattern == "" &&
|
||||
ie.expr.cellRef.row > 0 && ie.expr.cellRef.col > 0 &&
|
||||
!isMergeOp(ie.expr.replacement) &&
|
||||
ie.expr.cellRef.rowOp == "" && ie.expr.cellRef.colOp == ""
|
||||
}
|
||||
|
||||
// processCellExprs handles cell-level expressions: merge/split, row/col ops, table ops,
|
||||
// and batches consecutive whole-cell replacements for efficiency.
|
||||
func (c *DocsSedCmd) processCellExprs(ctx context.Context, u *ui.UI, account, id string, cellExprs []indexedExpr) (int, error) {
|
||||
totalReplaced := 0
|
||||
i := 0
|
||||
for i < len(cellExprs) {
|
||||
ie := cellExprs[i]
|
||||
|
||||
// Merge/split/unmerge ops run individually
|
||||
if isMergeOp(ie.expr.replacement) {
|
||||
if err := c.runTableMerge(ctx, u, account, id, ie.expr); err != nil {
|
||||
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
totalReplaced++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Row/col ops run individually
|
||||
if ie.expr.cellRef != nil && (ie.expr.cellRef.rowOp != "" || ie.expr.cellRef.colOp != "") {
|
||||
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
|
||||
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
totalReplaced++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Table-level ops (delete table) run individually
|
||||
if ie.expr.tableRef != 0 {
|
||||
if err := c.runTableOp(ctx, u, account, id, ie.expr); err != nil {
|
||||
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
totalReplaced++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect consecutive whole-cell replacements for the same table
|
||||
if canBatchCell(ie) {
|
||||
tableIdx := ie.expr.cellRef.tableIndex
|
||||
batch := []indexedExpr{ie}
|
||||
j := i + 1
|
||||
for j < len(cellExprs) && canBatchCell(cellExprs[j]) && cellExprs[j].expr.cellRef.tableIndex == tableIdx {
|
||||
batch = append(batch, cellExprs[j])
|
||||
j++
|
||||
}
|
||||
|
||||
if len(batch) > 1 {
|
||||
if err := c.runBatchCellReplace(ctx, u, account, id, batch); err != nil {
|
||||
return totalReplaced, fmt.Errorf("cell batch (expressions %d-%d): %w", batch[0].index+1, batch[len(batch)-1].index+1, err)
|
||||
}
|
||||
totalReplaced += len(batch)
|
||||
} else {
|
||||
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
|
||||
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
totalReplaced++
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Wildcard or other cell ops run individually
|
||||
if err := c.runTableCellReplace(ctx, u, account, id, ie.expr); err != nil {
|
||||
return totalReplaced, fmt.Errorf("expression %d: %w", ie.index+1, err)
|
||||
}
|
||||
totalReplaced++
|
||||
i++
|
||||
}
|
||||
return totalReplaced, nil
|
||||
}
|
||||
|
||||
func (c *DocsSedCmd) runNative(ctx context.Context, u *ui.UI, account, docID, pattern, replacement string) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
// Build native replace request with regex
|
||||
requests := []*docs.Request{
|
||||
{
|
||||
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
||||
ContainsText: &docs.SubstringMatchCriteria{
|
||||
Text: pattern,
|
||||
MatchCase: true,
|
||||
SearchByRegex: true,
|
||||
},
|
||||
ReplaceText: replacement,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := batchUpdate(ctx, docsSvc, docID, requests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update document: %w", err)
|
||||
}
|
||||
|
||||
// Get replacement count from response
|
||||
replaced := int64(0)
|
||||
if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil {
|
||||
replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", replaced}, sedOutputKV{"native", true})
|
||||
}
|
||||
391
internal/cmd/docs_sed_brace.go
Normal file
391
internal/cmd/docs_sed_brace.go
Normal file
@ -0,0 +1,391 @@
|
||||
// Package cmd provides CLI commands for Google Docs operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// indentNotSet is the sentinel value for braceExpr.Indent meaning "not specified".
|
||||
// Any non-negative value means the indent level was explicitly set.
|
||||
const indentNotSet = -1
|
||||
|
||||
// braceExpr represents a fully parsed brace expression from SEDMAT syntax.
|
||||
// It captures all formatting, structural, and semantic attributes specified
|
||||
// within a {flags} block in a replacement string.
|
||||
type braceExpr struct {
|
||||
// Boolean flags (nil = not set, *true = enabled, *false = negated)
|
||||
Bold *bool // {b} = true, {!b} = false
|
||||
Italic *bool
|
||||
Underline *bool
|
||||
Strike *bool
|
||||
Code *bool
|
||||
Sup *bool
|
||||
Sub *bool
|
||||
SmallCaps *bool
|
||||
|
||||
// Boolean flags with inline scoping
|
||||
InlineSpans []inlineSpan // {b=Warning} → span with text + flags
|
||||
|
||||
// Value flags
|
||||
Text string // t= (empty = not set, "$0" = default)
|
||||
Color string // c= (hex or named, resolved to hex)
|
||||
Bg string // z= (hex or named, resolved to hex)
|
||||
Font string // f=
|
||||
Size float64 // s= (0 = not set)
|
||||
URL string // u=
|
||||
Heading string // h= ("t","s","1"-"6","0", "" = not set)
|
||||
Leading float64 // l= (0 = not set)
|
||||
Align string // a= ("left","center","right","justify")
|
||||
Opacity int // o= (0 = not set, 100 = default)
|
||||
Indent int // n= (-1 = not set)
|
||||
Kerning float64 // k=
|
||||
Width int // x=
|
||||
Height int // y=
|
||||
|
||||
// Paragraph spacing
|
||||
SpacingAbove float64 // p= first value
|
||||
SpacingBelow float64 // p= second value (or same as above if single)
|
||||
SpacingSet bool // whether p= was specified
|
||||
|
||||
// Effect
|
||||
Effect string // e=
|
||||
|
||||
// Columns
|
||||
Cols int // cols= (0 = not set)
|
||||
|
||||
// Special flags
|
||||
Reset bool // {0} — explicit full reset
|
||||
NoReset bool // {!0} — opt out of implicit reset (additive mode)
|
||||
Break string // += ("" = horizontal rule when + present, "p","c","s")
|
||||
HasBreak bool // whether + was present
|
||||
Comment string // "=text
|
||||
Bookmark string // @=name
|
||||
|
||||
// Checkbox (tri-state)
|
||||
Check *bool // nil = no checkbox, true = checked, false = unchecked
|
||||
|
||||
// Table of contents
|
||||
TOC int // toc depth (0 = not set, -1 = unlimited)
|
||||
HasTOC bool // whether toc was specified
|
||||
|
||||
// Image ref (pattern-side)
|
||||
ImgRef string // img=
|
||||
|
||||
// Table ref (pattern-side)
|
||||
TableRef string // T= (raw value for further parsing)
|
||||
}
|
||||
|
||||
// inlineSpan represents an inline text span with associated boolean flags.
|
||||
// Used for inline scoping like {b=Warning} where "Warning" is bolded inline.
|
||||
type inlineSpan struct {
|
||||
Text string // The text content of the span
|
||||
Flags []string // Which boolean flags apply: "b", "i", "^", etc.
|
||||
}
|
||||
|
||||
// boolFlagMap maps short and long names to canonical short names.
|
||||
var boolFlagMap = map[string]string{
|
||||
"b": "b",
|
||||
"bold": "b",
|
||||
"i": "i",
|
||||
"italic": "i",
|
||||
"_": "_",
|
||||
"underline": "_",
|
||||
"-": "-",
|
||||
"strike": "-",
|
||||
"#": "#",
|
||||
"code": "#",
|
||||
"^": "^",
|
||||
"sup": "^",
|
||||
",": ",",
|
||||
"sub": ",",
|
||||
"w": "w",
|
||||
"smallcaps": "w",
|
||||
}
|
||||
|
||||
|
||||
// parseBraceExpr parses the content inside a brace expression.
|
||||
// Input is the content between { }, e.g. for `{b c=red t=hello}` the input is `b c=red t=hello`.
|
||||
func parseBraceExpr(s string) (*braceExpr, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return &braceExpr{Indent: indentNotSet}, nil // -1 means not set
|
||||
}
|
||||
|
||||
expr := &braceExpr{Indent: indentNotSet} // -1 means not set
|
||||
|
||||
// Handle comment first — it consumes everything after "=
|
||||
if idx := strings.Index(s, `"=`); idx >= 0 {
|
||||
expr.Comment = s[idx+2:]
|
||||
s = strings.TrimSpace(s[:idx])
|
||||
}
|
||||
|
||||
// Check for reset flag at start
|
||||
if strings.HasPrefix(s, "0") {
|
||||
expr.Reset = true
|
||||
s = strings.TrimPrefix(s, "0")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// Tokenize remaining content
|
||||
tokens := tokenizeBraceContent(s)
|
||||
|
||||
for _, tok := range tokens {
|
||||
if err := parseBraceToken(tok, expr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// tokenizeBraceContent splits brace content into tokens.
|
||||
// Tokens are space-separated, but values can contain spaces if quoted.
|
||||
func tokenizeBraceContent(s string) []string {
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inQuote:
|
||||
if c == quoteChar {
|
||||
inQuote = false
|
||||
}
|
||||
current.WriteByte(c)
|
||||
case c == '"' || c == '\'':
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
current.WriteByte(c)
|
||||
case c == ' ' || c == '\t':
|
||||
if current.Len() > 0 {
|
||||
tokens = append(tokens, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// parseBraceToken parses a single token and updates the braceExpr.
|
||||
func parseBraceToken(tok string, expr *braceExpr) error {
|
||||
// Handle break flag: + or +=value
|
||||
if tok == "+" || strings.HasPrefix(tok, "+=") {
|
||||
expr.HasBreak = true
|
||||
if strings.HasPrefix(tok, "+=") {
|
||||
expr.Break = tok[2:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle bookmark: @=name
|
||||
if strings.HasPrefix(tok, "@=") {
|
||||
expr.Bookmark = tok[2:]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle negation: !flag
|
||||
if strings.HasPrefix(tok, "!") {
|
||||
flagName := tok[1:]
|
||||
// {!0} = opt out of implicit reset (additive mode)
|
||||
if flagName == "0" {
|
||||
expr.NoReset = true
|
||||
return nil
|
||||
}
|
||||
if canon, ok := boolFlagMap[flagName]; ok {
|
||||
setBoolFlag(expr, canon, false)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown negated flag: %s", flagName)
|
||||
}
|
||||
|
||||
// Check for key=value
|
||||
eqIdx := strings.Index(tok, "=")
|
||||
if eqIdx >= 0 {
|
||||
key := tok[:eqIdx]
|
||||
val := tok[eqIdx+1:]
|
||||
return parseBraceKeyValue(key, val, expr)
|
||||
}
|
||||
|
||||
// Bare flag (no =)
|
||||
return parseBareFlag(tok, expr)
|
||||
}
|
||||
|
||||
// parseBraceKeyValue handles key=value tokens.
|
||||
func parseBraceKeyValue(key, val string, expr *braceExpr) error {
|
||||
// Check if it's a boolean flag with inline scoping (e.g., b=Warning)
|
||||
if canon, ok := boolFlagMap[key]; ok {
|
||||
// This is inline scoping: {b=text} means bold just "text"
|
||||
expr.InlineSpans = append(expr.InlineSpans, inlineSpan{
|
||||
Text: val,
|
||||
Flags: []string{canon},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value flags
|
||||
switch key {
|
||||
case "t", "text":
|
||||
expr.Text = val
|
||||
case "c", "color":
|
||||
expr.Color = resolveColor(val)
|
||||
case "z", "bg":
|
||||
expr.Bg = resolveColor(val)
|
||||
case "f", "font":
|
||||
expr.Font = val
|
||||
case "s", "size":
|
||||
if n, err := strconv.ParseFloat(val, 64); err == nil && n > 0 {
|
||||
expr.Size = n
|
||||
}
|
||||
case "u", "url":
|
||||
expr.URL = val
|
||||
case "h", "heading":
|
||||
expr.Heading = val
|
||||
case "l", "leading":
|
||||
if n, err := strconv.ParseFloat(val, 64); err == nil && n > 0 {
|
||||
expr.Leading = n
|
||||
}
|
||||
case "a", "align":
|
||||
expr.Align = strings.ToLower(val)
|
||||
case "o", "opacity":
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 100 {
|
||||
expr.Opacity = n
|
||||
}
|
||||
case "n", "indent":
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
expr.Indent = n
|
||||
}
|
||||
case "k", "kerning":
|
||||
if n, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
expr.Kerning = n
|
||||
}
|
||||
case "x", "width":
|
||||
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||
expr.Width = n
|
||||
}
|
||||
case "y", "height":
|
||||
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||
expr.Height = n
|
||||
}
|
||||
case "p", "spacing":
|
||||
parseSpacing(val, expr)
|
||||
case "e", "effect":
|
||||
expr.Effect = val
|
||||
case "cols":
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 1 {
|
||||
expr.Cols = n
|
||||
}
|
||||
case "check":
|
||||
switch strings.ToLower(val) {
|
||||
case "y", "yes", strTrue, "1":
|
||||
t := true
|
||||
expr.Check = &t
|
||||
case "n", "no", "false", "0":
|
||||
f := false
|
||||
expr.Check = &f
|
||||
}
|
||||
case "toc":
|
||||
expr.HasTOC = true
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
expr.TOC = n
|
||||
} else {
|
||||
expr.TOC = -1 // unlimited
|
||||
}
|
||||
case "img":
|
||||
expr.ImgRef = val
|
||||
case "T":
|
||||
expr.TableRef = val
|
||||
default:
|
||||
return fmt.Errorf("unknown key: %s", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseBareFlag handles bare flags without = (e.g., {b}, {check}, {toc}).
|
||||
func parseBareFlag(tok string, expr *braceExpr) error {
|
||||
// Check boolean flags
|
||||
if canon, ok := boolFlagMap[tok]; ok {
|
||||
setBoolFlag(expr, canon, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special bare flags
|
||||
switch tok {
|
||||
case "check":
|
||||
// Bare check = unchecked checkbox
|
||||
f := false
|
||||
expr.Check = &f
|
||||
case "toc":
|
||||
expr.HasTOC = true
|
||||
expr.TOC = -1 // unlimited
|
||||
// Bare value flags reset to defaults (per SEDMAT spec)
|
||||
case "t", "text":
|
||||
expr.Text = "$0"
|
||||
case "c", "color":
|
||||
expr.Color = "#000000" // black
|
||||
case "z", "bg":
|
||||
expr.Bg = "" // clear (no background)
|
||||
case "f", "font":
|
||||
expr.Font = "Arial"
|
||||
case "s", "size":
|
||||
expr.Size = 11
|
||||
case "h", "heading":
|
||||
expr.Heading = "1" // default to HEADING_1
|
||||
case "p", "spacing":
|
||||
expr.SpacingSet = true
|
||||
// Reset to defaults — SpacingAbove/Below stay 0
|
||||
case "cols":
|
||||
expr.Cols = 1
|
||||
default:
|
||||
return fmt.Errorf("unknown flag: %s", tok)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSpacing parses the p= flag value: "12" (both) or "12,6" (above,below).
|
||||
func parseSpacing(val string, expr *braceExpr) {
|
||||
expr.SpacingSet = true
|
||||
if idx := strings.Index(val, ","); idx >= 0 {
|
||||
if above, err := strconv.ParseFloat(val[:idx], 64); err == nil {
|
||||
expr.SpacingAbove = above
|
||||
}
|
||||
if below, err := strconv.ParseFloat(val[idx+1:], 64); err == nil {
|
||||
expr.SpacingBelow = below
|
||||
}
|
||||
} else {
|
||||
if v, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
expr.SpacingAbove = v
|
||||
expr.SpacingBelow = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setBoolFlag sets a boolean flag on braceExpr to the given value.
|
||||
func setBoolFlag(expr *braceExpr, canon string, val bool) {
|
||||
switch canon {
|
||||
case "b":
|
||||
expr.Bold = &val
|
||||
case "i":
|
||||
expr.Italic = &val
|
||||
case "_":
|
||||
expr.Underline = &val
|
||||
case "-":
|
||||
expr.Strike = &val
|
||||
case "#":
|
||||
expr.Code = &val
|
||||
case "^":
|
||||
expr.Sup = &val
|
||||
case ",":
|
||||
expr.Sub = &val
|
||||
case "w":
|
||||
expr.SmallCaps = &val
|
||||
}
|
||||
}
|
||||
481
internal/cmd/docs_sed_brace_format.go
Normal file
481
internal/cmd/docs_sed_brace_format.go
Normal file
@ -0,0 +1,481 @@
|
||||
// Package cmd provides CLI commands for Google Docs operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
)
|
||||
|
||||
// buildBraceTextStyleRequests converts a braceExpr to UpdateTextStyle requests.
|
||||
// It handles boolean flags (bold, italic, etc.), value flags (color, font, size),
|
||||
// negation, and reset.
|
||||
func buildBraceTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
|
||||
if be == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle reset: explicit {0} OR implicit (default unless {!0}).
|
||||
// Every brace expression implicitly resets all formatting first,
|
||||
// making output deterministic regardless of inherited doc styles.
|
||||
// Use {!0} to opt into additive mode (preserve existing styles).
|
||||
if be.Reset || !be.NoReset {
|
||||
return buildResetTextStyleRequests(be, start, end)
|
||||
}
|
||||
|
||||
style := &docs.TextStyle{}
|
||||
var fields []string
|
||||
|
||||
// Boolean flags
|
||||
if be.Bold != nil {
|
||||
style.Bold = *be.Bold
|
||||
fields = append(fields, "bold")
|
||||
}
|
||||
if be.Italic != nil {
|
||||
style.Italic = *be.Italic
|
||||
fields = append(fields, "italic")
|
||||
}
|
||||
if be.Underline != nil {
|
||||
style.Underline = *be.Underline
|
||||
fields = append(fields, "underline")
|
||||
}
|
||||
if be.Strike != nil {
|
||||
style.Strikethrough = *be.Strike
|
||||
fields = append(fields, "strikethrough")
|
||||
}
|
||||
if be.SmallCaps != nil {
|
||||
style.SmallCaps = *be.SmallCaps
|
||||
fields = append(fields, "smallCaps")
|
||||
}
|
||||
if be.Sup != nil && *be.Sup {
|
||||
style.BaselineOffset = "SUPERSCRIPT"
|
||||
fields = append(fields, "baselineOffset")
|
||||
}
|
||||
if be.Sub != nil && *be.Sub {
|
||||
style.BaselineOffset = "SUBSCRIPT"
|
||||
fields = append(fields, "baselineOffset")
|
||||
}
|
||||
// Reset baseline if both sup and sub are explicitly false
|
||||
if be.Sup != nil && !*be.Sup && be.Sub != nil && !*be.Sub {
|
||||
style.BaselineOffset = "NONE"
|
||||
fields = append(fields, "baselineOffset")
|
||||
}
|
||||
|
||||
// Code flag: monospace font + grey background
|
||||
if be.Code != nil && *be.Code {
|
||||
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: "Courier New"}
|
||||
style.BackgroundColor = greyColor(codeBackgroundGrey)
|
||||
fields = append(fields, "weightedFontFamily", "backgroundColor")
|
||||
}
|
||||
|
||||
// Value flags
|
||||
if be.Font != "" {
|
||||
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: be.Font}
|
||||
fields = append(fields, "weightedFontFamily")
|
||||
}
|
||||
if be.Size > 0 {
|
||||
style.FontSize = &docs.Dimension{Magnitude: be.Size, Unit: "PT"}
|
||||
fields = append(fields, "fontSize")
|
||||
}
|
||||
if be.Color != "" {
|
||||
if r, g, b, ok := parseHexColor(be.Color); ok {
|
||||
style.ForegroundColor = &docs.OptionalColor{
|
||||
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
|
||||
}
|
||||
fields = append(fields, "foregroundColor")
|
||||
}
|
||||
}
|
||||
if be.Bg != "" {
|
||||
if r, g, b, ok := parseHexColor(be.Bg); ok {
|
||||
style.BackgroundColor = &docs.OptionalColor{
|
||||
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
|
||||
}
|
||||
fields = append(fields, "backgroundColor")
|
||||
}
|
||||
}
|
||||
if be.URL != "" {
|
||||
// Handle bookmark links (#name) vs regular URLs
|
||||
if strings.HasPrefix(be.URL, "#") {
|
||||
style.Link = &docs.Link{BookmarkId: be.URL[1:]}
|
||||
} else {
|
||||
style.Link = &docs.Link{Url: be.URL}
|
||||
}
|
||||
fields = append(fields, "link")
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*docs.Request{
|
||||
{
|
||||
UpdateTextStyle: &docs.UpdateTextStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
TextStyle: style,
|
||||
Fields: strings.Join(fields, ","),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resetFieldsStr is the pre-joined field mask for resetting all text formatting.
|
||||
// Package-level to avoid per-call allocation and string joining.
|
||||
var resetFieldsStr = strings.Join([]string{
|
||||
"bold", "italic", "underline", "strikethrough", "smallCaps",
|
||||
"baselineOffset", "foregroundColor", "backgroundColor",
|
||||
"fontSize", "weightedFontFamily", "link",
|
||||
}, ",")
|
||||
|
||||
// buildResetTextStyleRequests builds requests to clear all formatting, then apply any
|
||||
// additional flags specified after the reset (e.g., {0 b} = reset then bold).
|
||||
func buildResetTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
|
||||
requests := []*docs.Request{
|
||||
{
|
||||
UpdateTextStyle: &docs.UpdateTextStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
TextStyle: &docs.TextStyle{}, // Empty style = reset
|
||||
Fields: resetFieldsStr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Now apply any flags that were set alongside the reset
|
||||
// Create a copy without Reset flag and with NoReset to avoid recursion
|
||||
postReset := *be
|
||||
postReset.Reset = false
|
||||
postReset.NoReset = true
|
||||
|
||||
if braceExprHasTextFormat(&postReset) {
|
||||
requests = append(requests, buildBraceTextStyleRequests(&postReset, start, end)...)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// braceExprHasTextFormat returns true if the braceExpr has any text-level formatting.
|
||||
func braceExprHasTextFormat(be *braceExpr) bool {
|
||||
if be == nil {
|
||||
return false
|
||||
}
|
||||
return be.Bold != nil || be.Italic != nil || be.Underline != nil ||
|
||||
be.Strike != nil || be.Code != nil || be.Sup != nil ||
|
||||
be.Sub != nil || be.SmallCaps != nil || be.Color != "" ||
|
||||
be.Bg != "" || be.Font != "" || be.Size > 0 || be.URL != ""
|
||||
}
|
||||
|
||||
// buildBraceParagraphStyleRequests converts a braceExpr to paragraph-level requests.
|
||||
// Handles headings, alignment, indent, leading, spacing, bullets.
|
||||
func buildBraceParagraphStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
|
||||
if be == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var requests []*docs.Request
|
||||
|
||||
// Build paragraph style
|
||||
paraStyle := &docs.ParagraphStyle{}
|
||||
var paraFields []string
|
||||
|
||||
// Heading
|
||||
if be.Heading != "" {
|
||||
namedStyle := resolveHeading(be.Heading)
|
||||
paraStyle.NamedStyleType = namedStyle
|
||||
paraFields = append(paraFields, "namedStyleType")
|
||||
}
|
||||
|
||||
// Alignment
|
||||
if be.Align != "" {
|
||||
paraStyle.Alignment = resolveAlign(be.Align)
|
||||
paraFields = append(paraFields, "alignment")
|
||||
}
|
||||
|
||||
// Indent level (converted to indentStart in points)
|
||||
if be.Indent >= 0 {
|
||||
// Each indent level = 36pt (standard Google Docs indent)
|
||||
indentPt := float64(be.Indent) * indentPointsPerLevel
|
||||
paraStyle.IndentStart = &docs.Dimension{Magnitude: indentPt, Unit: "PT"}
|
||||
paraFields = append(paraFields, "indentStart")
|
||||
}
|
||||
|
||||
// Line height / leading
|
||||
if be.Leading > 0 {
|
||||
paraStyle.LineSpacing = be.Leading * 100 // 1.5 → 150
|
||||
paraFields = append(paraFields, "lineSpacing")
|
||||
}
|
||||
|
||||
// Paragraph spacing (above/below)
|
||||
if be.SpacingSet {
|
||||
if be.SpacingAbove > 0 || be.SpacingBelow > 0 {
|
||||
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: be.SpacingAbove, Unit: "PT"}
|
||||
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: be.SpacingBelow, Unit: "PT"}
|
||||
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
|
||||
} else {
|
||||
// Reset spacing to zero
|
||||
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: 0, Unit: "PT"}
|
||||
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: 0, Unit: "PT"}
|
||||
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
|
||||
}
|
||||
}
|
||||
|
||||
if len(paraFields) > 0 {
|
||||
requests = append(requests, &docs.Request{
|
||||
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
ParagraphStyle: paraStyle,
|
||||
Fields: strings.Join(paraFields, ","),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// buildBraceInlineRequests handles inline scoping — multiple styled spans within one replacement.
|
||||
// Each span has its own start/end positions and formatting.
|
||||
func buildBraceInlineRequests(spans []*braceSpan, baseIndex int64) []*docs.Request {
|
||||
if len(spans) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var requests []*docs.Request
|
||||
for _, span := range spans {
|
||||
if span.IsGlobal || span.Expr == nil {
|
||||
continue // Global spans handled separately
|
||||
}
|
||||
|
||||
start := baseIndex + int64(span.Start)
|
||||
end := baseIndex + int64(span.End)
|
||||
if end <= start {
|
||||
continue
|
||||
}
|
||||
|
||||
requests = append(requests, buildBraceTextStyleRequests(span.Expr, start, end)...)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// buildBraceBreakRequests handles break flags (+, +=p, +=c, +=s).
|
||||
// Returns requests to insert horizontal rules, page/column/section breaks.
|
||||
func buildBraceBreakRequests(be *braceExpr, insertIdx int64) []*docs.Request {
|
||||
if be == nil || !be.HasBreak {
|
||||
return nil
|
||||
}
|
||||
|
||||
var requests []*docs.Request
|
||||
|
||||
switch be.Break {
|
||||
case "p": // Page break
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertPageBreak: &docs.InsertPageBreakRequest{
|
||||
Location: &docs.Location{Index: insertIdx},
|
||||
},
|
||||
})
|
||||
case "c": // Column break
|
||||
// Column break is just a special character insert
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: insertIdx},
|
||||
Text: "\v", // Vertical tab = column break in Docs
|
||||
},
|
||||
})
|
||||
case "s": // Section break
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertSectionBreak: &docs.InsertSectionBreakRequest{
|
||||
Location: &docs.Location{Index: insertIdx},
|
||||
SectionType: "NEXT_PAGE",
|
||||
},
|
||||
})
|
||||
default: // "" = horizontal rule
|
||||
// Insert newline with bottom border
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: insertIdx},
|
||||
Text: "\n",
|
||||
},
|
||||
})
|
||||
requests = append(requests, buildHruleBorderRequest(insertIdx, insertIdx+1))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// braceExprToFormats converts a braceExpr to the existing format string slice
|
||||
// for backward compatibility with existing code paths.
|
||||
func braceExprToFormats(be *braceExpr) []string {
|
||||
if be == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var formats []string
|
||||
|
||||
// Boolean flags
|
||||
if be.Bold != nil && *be.Bold {
|
||||
formats = append(formats, "bold")
|
||||
}
|
||||
if be.Italic != nil && *be.Italic {
|
||||
formats = append(formats, "italic")
|
||||
}
|
||||
if be.Underline != nil && *be.Underline {
|
||||
formats = append(formats, "underline")
|
||||
}
|
||||
if be.Strike != nil && *be.Strike {
|
||||
formats = append(formats, "strikethrough")
|
||||
}
|
||||
if be.Code != nil && *be.Code {
|
||||
formats = append(formats, "code")
|
||||
}
|
||||
if be.Sup != nil && *be.Sup {
|
||||
formats = append(formats, "superscript")
|
||||
}
|
||||
if be.Sub != nil && *be.Sub {
|
||||
formats = append(formats, "subscript")
|
||||
}
|
||||
if be.SmallCaps != nil && *be.SmallCaps {
|
||||
formats = append(formats, "smallcaps")
|
||||
}
|
||||
|
||||
// Value flags
|
||||
if be.Font != "" {
|
||||
formats = append(formats, "font:"+be.Font)
|
||||
}
|
||||
if be.Size > 0 {
|
||||
formats = append(formats, "size:"+formatFloat(be.Size))
|
||||
}
|
||||
if be.Color != "" {
|
||||
formats = append(formats, "color:"+be.Color)
|
||||
}
|
||||
if be.Bg != "" {
|
||||
formats = append(formats, "bg:"+be.Bg)
|
||||
}
|
||||
if be.URL != "" {
|
||||
formats = append(formats, "link:"+be.URL)
|
||||
}
|
||||
|
||||
// Heading
|
||||
if be.Heading != "" {
|
||||
level := be.Heading
|
||||
switch level {
|
||||
case "t":
|
||||
formats = append(formats, "title")
|
||||
case "s":
|
||||
formats = append(formats, "subtitle")
|
||||
case "0":
|
||||
formats = append(formats, "normal")
|
||||
default:
|
||||
formats = append(formats, "heading"+level)
|
||||
}
|
||||
}
|
||||
|
||||
// Alignment
|
||||
if be.Align != "" {
|
||||
formats = append(formats, "align:"+be.Align)
|
||||
}
|
||||
|
||||
return formats
|
||||
}
|
||||
|
||||
// formatFloat formats a float64 without unnecessary trailing zeros.
|
||||
func formatFloat(f float64) string {
|
||||
// Check if it's a whole number
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
// Format with precision, trim trailing zeros
|
||||
s := strconv.FormatFloat(f, 'f', -1, 64)
|
||||
return s
|
||||
}
|
||||
|
||||
// hasBraceTextFormat checks if braceExpr has formatting that requires text styling.
|
||||
func hasBraceTextFormat(be *braceExpr) bool {
|
||||
return braceExprHasTextFormat(be)
|
||||
}
|
||||
|
||||
// hasBraceParagraphFormat checks if braceExpr has formatting that requires paragraph styling.
|
||||
func hasBraceParagraphFormat(be *braceExpr) bool {
|
||||
if be == nil {
|
||||
return false
|
||||
}
|
||||
return be.Heading != "" || be.Align != "" || be.Indent >= 0 ||
|
||||
be.Leading > 0 || be.SpacingSet
|
||||
}
|
||||
|
||||
// mergeBraceSpans merges multiple braceSpans into a single braceExpr for global formatting.
|
||||
// Only global spans (those that apply to the entire match) are merged; non-global spans
|
||||
// represent inline-scoped formatting (e.g., {b=Warning}) and are handled separately
|
||||
// by buildBraceInlineRequests, which applies them at their specific positions.
|
||||
func mergeBraceSpans(spans []*braceSpan) *braceExpr {
|
||||
merged := &braceExpr{Indent: indentNotSet}
|
||||
for _, span := range spans {
|
||||
if span.IsGlobal && span.Expr != nil {
|
||||
// Copy global flags to merged
|
||||
if span.Expr.Bold != nil {
|
||||
merged.Bold = span.Expr.Bold
|
||||
}
|
||||
if span.Expr.Italic != nil {
|
||||
merged.Italic = span.Expr.Italic
|
||||
}
|
||||
if span.Expr.Underline != nil {
|
||||
merged.Underline = span.Expr.Underline
|
||||
}
|
||||
if span.Expr.Strike != nil {
|
||||
merged.Strike = span.Expr.Strike
|
||||
}
|
||||
if span.Expr.Code != nil {
|
||||
merged.Code = span.Expr.Code
|
||||
}
|
||||
if span.Expr.Sup != nil {
|
||||
merged.Sup = span.Expr.Sup
|
||||
}
|
||||
if span.Expr.Sub != nil {
|
||||
merged.Sub = span.Expr.Sub
|
||||
}
|
||||
if span.Expr.SmallCaps != nil {
|
||||
merged.SmallCaps = span.Expr.SmallCaps
|
||||
}
|
||||
if span.Expr.Color != "" {
|
||||
merged.Color = span.Expr.Color
|
||||
}
|
||||
if span.Expr.Bg != "" {
|
||||
merged.Bg = span.Expr.Bg
|
||||
}
|
||||
if span.Expr.Font != "" {
|
||||
merged.Font = span.Expr.Font
|
||||
}
|
||||
if span.Expr.Size > 0 {
|
||||
merged.Size = span.Expr.Size
|
||||
}
|
||||
if span.Expr.URL != "" {
|
||||
merged.URL = span.Expr.URL
|
||||
}
|
||||
if span.Expr.Heading != "" {
|
||||
merged.Heading = span.Expr.Heading
|
||||
}
|
||||
if span.Expr.Align != "" {
|
||||
merged.Align = span.Expr.Align
|
||||
}
|
||||
if span.Expr.Leading > 0 {
|
||||
merged.Leading = span.Expr.Leading
|
||||
}
|
||||
if span.Expr.SpacingSet {
|
||||
merged.SpacingSet = true
|
||||
merged.SpacingAbove = span.Expr.SpacingAbove
|
||||
merged.SpacingBelow = span.Expr.SpacingBelow
|
||||
}
|
||||
if span.Expr.Indent >= 0 {
|
||||
merged.Indent = span.Expr.Indent
|
||||
}
|
||||
if span.Expr.Reset {
|
||||
merged.Reset = true
|
||||
}
|
||||
if span.Expr.HasBreak {
|
||||
merged.HasBreak = true
|
||||
merged.Break = span.Expr.Break
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// (Legacy attrsTobraceExpr removed — sedAttrs no longer exists)
|
||||
296
internal/cmd/docs_sed_brace_match.go
Normal file
296
internal/cmd/docs_sed_brace_match.go
Normal file
@ -0,0 +1,296 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
// braceSpan represents a positioned brace expression within a replacement string.
|
||||
// It tracks where in the output text the formatting should be applied.
|
||||
type braceSpan struct {
|
||||
Expr *braceExpr // The parsed brace expression
|
||||
Start int // Start position in the cleaned output text
|
||||
End int // End position in the cleaned output text
|
||||
IsGlobal bool // True if this applies to the whole match (e.g., {b} alone)
|
||||
RawBraces string // Original {content} for debugging
|
||||
}
|
||||
|
||||
// findBraceExprs finds all `{...}` groups in a replacement string and returns
|
||||
// the cleaned text plus positioned spans. Handles:
|
||||
// - `{b}` as entire replacement → whole-match formatting
|
||||
// - `{b=text}` inline → inline span at position
|
||||
// - Multiple brace groups: `H{,=2}O` → "H2O" with subscript on "2"
|
||||
// - Escaped braces: `\{` and `\}` are literals
|
||||
func findBraceExprs(replacement string) (string, []*braceSpan) {
|
||||
if !strings.Contains(replacement, "{") {
|
||||
return replacement, nil
|
||||
}
|
||||
|
||||
var spans []*braceSpan
|
||||
var cleaned strings.Builder
|
||||
cleanedPos := 0
|
||||
|
||||
i := 0
|
||||
for i < len(replacement) {
|
||||
// Handle escaped braces
|
||||
if i+1 < len(replacement) && replacement[i] == '\\' {
|
||||
if replacement[i+1] == '{' || replacement[i+1] == '}' {
|
||||
cleaned.WriteByte(replacement[i+1])
|
||||
cleanedPos++
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Other escape sequences
|
||||
cleaned.WriteByte(replacement[i])
|
||||
cleanedPos++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for opening brace
|
||||
if replacement[i] == '{' {
|
||||
// Find matching closing brace (handle nesting for error detection)
|
||||
closeIdx := findMatchingBrace(replacement, i)
|
||||
if closeIdx < 0 {
|
||||
// Unmatched brace — treat as literal
|
||||
cleaned.WriteByte('{')
|
||||
cleanedPos++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
braceContent := replacement[i+1 : closeIdx]
|
||||
rawBraces := replacement[i : closeIdx+1]
|
||||
|
||||
expr, err := parseBraceExpr(braceContent)
|
||||
if err != nil {
|
||||
// Parse error — treat as literal
|
||||
cleaned.WriteByte('{')
|
||||
cleanedPos++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine span behavior
|
||||
span := &braceSpan{
|
||||
Expr: expr,
|
||||
Start: cleanedPos,
|
||||
RawBraces: rawBraces,
|
||||
}
|
||||
|
||||
// Check if this has inline spans with text
|
||||
if len(expr.InlineSpans) > 0 {
|
||||
// Handle inline scoping: write the span text
|
||||
for _, is := range expr.InlineSpans {
|
||||
spanStart := cleanedPos
|
||||
cleaned.WriteString(is.Text)
|
||||
cleanedPos += len(is.Text)
|
||||
span.End = cleanedPos
|
||||
|
||||
// Create a span for each inline text
|
||||
inlineExpr := &braceExpr{Indent: indentNotSet}
|
||||
for _, flag := range is.Flags {
|
||||
setBoolFlag(inlineExpr, flag, true)
|
||||
}
|
||||
spans = append(spans, &braceSpan{
|
||||
Expr: inlineExpr,
|
||||
Start: spanStart,
|
||||
End: cleanedPos,
|
||||
RawBraces: rawBraces,
|
||||
})
|
||||
}
|
||||
i = closeIdx + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if Text is set explicitly
|
||||
if expr.Text != "" && expr.Text != "$0" {
|
||||
// Write the explicit text
|
||||
span.Start = cleanedPos
|
||||
cleaned.WriteString(expr.Text)
|
||||
cleanedPos += len(expr.Text)
|
||||
span.End = cleanedPos
|
||||
spans = append(spans, span)
|
||||
i = closeIdx + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a global expression (applies to whole match)
|
||||
// Global if:
|
||||
// 1. The brace is the entire replacement, or
|
||||
// 2. Text is set to $0 (explicit whole-match), or
|
||||
// 3. No inline spans and no explicit text (implicit whole-match)
|
||||
isGlobal := isGlobalBraceExpr(replacement, i, closeIdx)
|
||||
if isGlobal {
|
||||
span.IsGlobal = true
|
||||
span.End = -1 // Will be set by caller to match length
|
||||
}
|
||||
|
||||
spans = append(spans, span)
|
||||
i = closeIdx + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular character
|
||||
cleaned.WriteByte(replacement[i])
|
||||
cleanedPos++
|
||||
i++
|
||||
}
|
||||
|
||||
return cleaned.String(), spans
|
||||
}
|
||||
|
||||
// findMatchingBrace finds the index of the closing brace matching the one at pos.
|
||||
// Returns -1 if no matching brace is found. Correctly handles escaped braces (\{ and \}).
|
||||
func findMatchingBrace(s string, pos int) int {
|
||||
if pos >= len(s) || s[pos] != '{' {
|
||||
return -1
|
||||
}
|
||||
depth := 1
|
||||
for i := pos + 1; i < len(s); i++ {
|
||||
if s[i] == '\\' {
|
||||
i++ // skip next character (escaped)
|
||||
continue
|
||||
}
|
||||
switch s[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// isGlobalBraceExpr determines if a brace expression should apply to the whole match.
|
||||
// Returns true if:
|
||||
// 1. The brace expression is the entire replacement (no surrounding text), OR
|
||||
// 2. The brace is a trailing-only expression: text{flags} with nothing after.
|
||||
// In SEDMAT, `text{b c=red}` means "format all of 'text' with bold+red".
|
||||
func isGlobalBraceExpr(replacement string, openIdx, closeIdx int) bool {
|
||||
before := strings.TrimSpace(replacement[:openIdx])
|
||||
after := strings.TrimSpace(replacement[closeIdx+1:])
|
||||
// Standalone brace (only thing in replacement) — global
|
||||
if before == "" && after == "" {
|
||||
return true
|
||||
}
|
||||
// Leading brace ({f=Georgia}text) — applies to all following text
|
||||
if before == "" && after != "" {
|
||||
return true
|
||||
}
|
||||
// Trailing brace (text{b}) — applies to all preceding text
|
||||
if after == "" && before != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasBraceFormatting returns true if the replacement contains brace formatting.
|
||||
// Used to determine if fast-path native replacement can be used.
|
||||
func hasBraceFormatting(replacement string) bool {
|
||||
// Look for { not preceded by \
|
||||
for i := 0; i < len(replacement); i++ {
|
||||
if replacement[i] == '{' {
|
||||
if i == 0 || replacement[i-1] != '\\' {
|
||||
// Check if there's content and a closing brace
|
||||
closeIdx := findMatchingBrace(replacement, i)
|
||||
if closeIdx > i+1 {
|
||||
// Has non-empty brace content
|
||||
content := replacement[i+1 : closeIdx]
|
||||
// Verify it looks like a brace expr (has known flags or key=value)
|
||||
if looksLikeBraceExpr(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// valueKeySet contains all value flag prefixes for fast brace expression detection.
|
||||
// Package-level to avoid per-call allocation.
|
||||
var valueKeySet = func() map[string]bool {
|
||||
keys := []string{
|
||||
"t=", "text=", "c=", "color=", "z=", "bg=", "f=", "font=",
|
||||
"s=", "size=", "u=", "url=", "h=", "heading=", "l=", "leading=",
|
||||
"a=", "align=", "o=", "opacity=", "n=", "indent=", "k=", "kerning=",
|
||||
"x=", "width=", "y=", "height=", "p=", "spacing=", "e=", "effect=",
|
||||
"cols=", "check", "toc", "img=", "T=", "@=", `"=`,
|
||||
}
|
||||
m := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// looksLikeBraceExpr returns true if the content looks like a valid brace expression.
|
||||
// Used for heuristic detection to distinguish {formatting} from literal braces.
|
||||
func looksLikeBraceExpr(content string) bool {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known patterns
|
||||
// Reset
|
||||
if content == "0" || strings.HasPrefix(content, "0 ") {
|
||||
return true
|
||||
}
|
||||
// Boolean flags
|
||||
for flag := range boolFlagMap {
|
||||
if content == flag || strings.HasPrefix(content, flag+" ") || strings.HasPrefix(content, flag+"=") {
|
||||
return true
|
||||
}
|
||||
if content == "!"+flag || strings.HasPrefix(content, "!"+flag+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Value flags — check if content contains any known key prefix
|
||||
for key := range valueKeySet {
|
||||
if strings.Contains(content, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Break flag
|
||||
if content == "+" || strings.HasPrefix(content, "+=") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// braceExprHasAnyFormat returns true if the expression sets any formatting.
|
||||
// Used to filter out empty or no-op expressions.
|
||||
func braceExprHasAnyFormat(expr *braceExpr) bool {
|
||||
if expr == nil {
|
||||
return false
|
||||
}
|
||||
// Check boolean flags
|
||||
if expr.Bold != nil || expr.Italic != nil || expr.Underline != nil ||
|
||||
expr.Strike != nil || expr.Code != nil || expr.Sup != nil ||
|
||||
expr.Sub != nil || expr.SmallCaps != nil {
|
||||
return true
|
||||
}
|
||||
// Check inline spans
|
||||
if len(expr.InlineSpans) > 0 {
|
||||
return true
|
||||
}
|
||||
// Check value flags (note: Indent -1 means not set, 0+ means explicitly set)
|
||||
if expr.Text != "" || expr.Color != "" || expr.Bg != "" || expr.Font != "" ||
|
||||
expr.Size > 0 || expr.URL != "" || expr.Heading != "" || expr.Leading > 0 ||
|
||||
expr.Align != "" || expr.Opacity > 0 || expr.Indent > indentNotSet || expr.Kerning != 0 ||
|
||||
expr.Width > 0 || expr.Height > 0 || expr.SpacingSet || expr.Effect != "" ||
|
||||
expr.Cols > 0 {
|
||||
return true
|
||||
}
|
||||
// Check special flags
|
||||
if expr.Reset || expr.HasBreak || expr.Comment != "" || expr.Bookmark != "" ||
|
||||
expr.Check != nil || expr.HasTOC || expr.ImgRef != "" || expr.TableRef != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
584
internal/cmd/docs_sed_brace_pattern.go
Normal file
584
internal/cmd/docs_sed_brace_pattern.go
Normal file
@ -0,0 +1,584 @@
|
||||
// Package cmd provides CLI commands for Google Docs operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// braceTableRef represents a parsed {T=...} table reference for pattern-side addressing.
|
||||
// It captures table index, cell references, ranges, wildcards, and row/col operations.
|
||||
type braceTableRef struct {
|
||||
TableIndex int // 1-indexed, negative from end, 0 for * (all tables), math.MinInt32 reserved
|
||||
IsCreate bool // {T=3x4} — table creation spec
|
||||
CreateRows int
|
||||
CreateCols int
|
||||
HasHeader bool // {T=3x4:header}
|
||||
|
||||
// Cell addressing
|
||||
Cell *tableCellRef // reuse existing struct for single cell
|
||||
Row int // 1-indexed row for cell ref, 0 for wildcard
|
||||
Col int // 1-indexed col for cell ref, 0 for wildcard
|
||||
IsExcel bool // true if Excel-style (A1), false if row,col
|
||||
ExcelCell string // original Excel ref (e.g., "A1")
|
||||
|
||||
// Range addressing
|
||||
HasRange bool // A1:C3 or 1,1:3,3
|
||||
EndRow int // end row for range
|
||||
EndCol int // end col for range
|
||||
|
||||
// Wildcards
|
||||
IsAllCells bool // {T=1!*} — all cells in table
|
||||
RowWild bool // {T=1!1,*} — entire row
|
||||
ColWild bool // {T=1!*,2} — entire column
|
||||
|
||||
// Row/column operations
|
||||
RowOp string // "+2" (insert before 2), "$+" (append), "2" (delete row 2), "-1" (delete last)
|
||||
ColOp string // same semantics for columns
|
||||
}
|
||||
|
||||
// braceImgRef represents a parsed {img=...} image reference for pattern-side addressing.
|
||||
type braceImgRef struct {
|
||||
Index int // 1-indexed, negative from end, 0 for pattern match
|
||||
IsAll bool // {img=*}
|
||||
Pattern string // regex pattern for alt text matching
|
||||
Regex *regexp.Regexp // compiled pattern (nil if positional)
|
||||
}
|
||||
|
||||
// parseBraceTableRef parses a {T=...} table reference spec.
|
||||
// Supports: {T=1}, {T=-1}, {T=*}, {T=3x4}, {T=3x4:header},
|
||||
// {T=1!A1}, {T=1!1,2}, {T=1!1,*}, {T=1!*,2}, {T=1!*},
|
||||
// {T=1!A1:C3}, {T=1!row=+2}, {T=1!row=$+}, {T=1!col=+3}, etc.
|
||||
func parseBraceTableRef(spec string) (*braceTableRef, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, fmt.Errorf("empty table spec")
|
||||
}
|
||||
|
||||
ref := &braceTableRef{}
|
||||
|
||||
// Check for table creation: NxM or NxM:header
|
||||
if isTableCreateSpec(spec) {
|
||||
return parseTableCreateBrace(spec)
|
||||
}
|
||||
|
||||
// Split by ! to separate table index from cell spec
|
||||
parts := strings.SplitN(spec, "!", 2)
|
||||
tableSpec := parts[0]
|
||||
var cellSpec string
|
||||
if len(parts) > 1 {
|
||||
cellSpec = parts[1]
|
||||
}
|
||||
|
||||
// Parse table index
|
||||
if tableSpec == "*" {
|
||||
ref.TableIndex = 0 // 0 means all tables
|
||||
} else {
|
||||
idx, err := strconv.Atoi(tableSpec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid table index %q: %w", tableSpec, err)
|
||||
}
|
||||
if idx == 0 {
|
||||
return nil, fmt.Errorf("table index cannot be 0; use * for all")
|
||||
}
|
||||
ref.TableIndex = idx
|
||||
}
|
||||
|
||||
// If no cell spec, we're done
|
||||
if cellSpec == "" {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Parse cell spec
|
||||
return parseBraceCellSpec(ref, cellSpec)
|
||||
}
|
||||
|
||||
// isTableCreateSpec checks if spec looks like a table creation (NxM or NxM:header).
|
||||
func isTableCreateSpec(spec string) bool {
|
||||
// Must contain 'x' and no '!'
|
||||
if strings.Contains(spec, "!") {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(spec)
|
||||
if !strings.Contains(lower, "x") {
|
||||
return false
|
||||
}
|
||||
// Must start with digit
|
||||
if len(spec) == 0 || spec[0] < '0' || spec[0] > '9' {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseTableCreateBrace parses {T=3x4} or {T=3x4:header}.
|
||||
func parseTableCreateBrace(spec string) (*braceTableRef, error) {
|
||||
ref := &braceTableRef{IsCreate: true}
|
||||
|
||||
// Check for :header suffix
|
||||
if idx := strings.Index(spec, ":"); idx >= 0 {
|
||||
suffix := strings.ToLower(strings.TrimSpace(spec[idx+1:]))
|
||||
if suffix != "header" {
|
||||
return nil, fmt.Errorf("invalid table create suffix %q (expected 'header')", suffix)
|
||||
}
|
||||
ref.HasHeader = true
|
||||
spec = spec[:idx]
|
||||
}
|
||||
|
||||
// Parse RxC
|
||||
lower := strings.ToLower(spec)
|
||||
parts := strings.SplitN(lower, "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid table create spec %q", spec)
|
||||
}
|
||||
|
||||
rows, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || rows < 1 || rows > 100 {
|
||||
return nil, fmt.Errorf("invalid row count in %q (must be 1-100)", spec)
|
||||
}
|
||||
cols, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || cols < 1 || cols > 26 {
|
||||
return nil, fmt.Errorf("invalid column count in %q (must be 1-26)", spec)
|
||||
}
|
||||
|
||||
ref.CreateRows = rows
|
||||
ref.CreateCols = cols
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// parseBraceCellSpec parses the cell specification after the ! separator.
|
||||
func parseBraceCellSpec(ref *braceTableRef, spec string) (*braceTableRef, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Check for wildcard: *
|
||||
if spec == "*" {
|
||||
ref.IsAllCells = true
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Check for row operation: row=+2, row=$+, row=2, row=-1
|
||||
if strings.HasPrefix(spec, "row=") {
|
||||
return parseRowOp(ref, spec[4:])
|
||||
}
|
||||
|
||||
// Check for column operation: col=+3, col=$+, col=3, col=-1
|
||||
if strings.HasPrefix(spec, "col=") {
|
||||
return parseColOp(ref, spec[4:])
|
||||
}
|
||||
|
||||
// Check for range: A1:C3 or 1,1:3,3
|
||||
if strings.Contains(spec, ":") {
|
||||
return parseCellRange(ref, spec)
|
||||
}
|
||||
|
||||
// Check for row,col format with wildcards
|
||||
if strings.Contains(spec, ",") {
|
||||
return parseRowColRef(ref, spec)
|
||||
}
|
||||
|
||||
// Try Excel-style reference (A1, B12, etc.)
|
||||
row, col, ok := parseExcelRef(spec)
|
||||
if ok {
|
||||
ref.Row = row
|
||||
ref.Col = col
|
||||
ref.IsExcel = true
|
||||
ref.ExcelCell = spec
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid cell spec %q", spec)
|
||||
}
|
||||
|
||||
// parseRowOp parses row operations: +2 (insert before 2), $+ (append), 2 (delete), -1 (delete last).
|
||||
func parseRowOp(ref *braceTableRef, val string) (*braceTableRef, error) {
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return nil, fmt.Errorf("empty row operation")
|
||||
}
|
||||
ref.RowOp = val
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// parseColOp parses column operations: +3 (insert before 3), $+ (append), 3 (delete), -1 (delete last).
|
||||
func parseColOp(ref *braceTableRef, val string) (*braceTableRef, error) {
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return nil, fmt.Errorf("empty column operation")
|
||||
}
|
||||
ref.ColOp = val
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// parseCellRange parses A1:C3 or 1,1:3,3 range syntax.
|
||||
func parseCellRange(ref *braceTableRef, spec string) (*braceTableRef, error) {
|
||||
parts := strings.SplitN(spec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid range %q", spec)
|
||||
}
|
||||
|
||||
startSpec := strings.TrimSpace(parts[0])
|
||||
endSpec := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse start cell
|
||||
startRow, startCol, err := parseCellCoord(startSpec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range start %q: %w", startSpec, err)
|
||||
}
|
||||
|
||||
// Parse end cell
|
||||
endRow, endCol, err := parseCellCoord(endSpec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range end %q: %w", endSpec, err)
|
||||
}
|
||||
|
||||
ref.Row = startRow
|
||||
ref.Col = startCol
|
||||
ref.EndRow = endRow
|
||||
ref.EndCol = endCol
|
||||
ref.HasRange = true
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// parseCellCoord parses a single cell coordinate (A1 or 1,2 format).
|
||||
func parseCellCoord(s string) (row, col int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Try row,col format
|
||||
if strings.Contains(s, ",") {
|
||||
parts := strings.SplitN(s, ",", 2)
|
||||
row, err = strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid row: %w", err)
|
||||
}
|
||||
col, err = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid col: %w", err)
|
||||
}
|
||||
return row, col, nil
|
||||
}
|
||||
|
||||
// Try Excel-style
|
||||
row, col, ok := parseExcelRef(s)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("invalid cell reference %q", s)
|
||||
}
|
||||
return row, col, nil
|
||||
}
|
||||
|
||||
// parseRowColRef parses R,C format with wildcard support.
|
||||
func parseRowColRef(ref *braceTableRef, spec string) (*braceTableRef, error) {
|
||||
parts := strings.SplitN(spec, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid row,col spec %q", spec)
|
||||
}
|
||||
|
||||
rowStr := strings.TrimSpace(parts[0])
|
||||
colStr := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse row
|
||||
if rowStr == "*" {
|
||||
ref.ColWild = true // *,N means entire column N
|
||||
ref.Row = 0
|
||||
} else {
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid row %q: %w", rowStr, err)
|
||||
}
|
||||
ref.Row = row
|
||||
}
|
||||
|
||||
// Parse col
|
||||
if colStr == "*" {
|
||||
ref.RowWild = true // N,* means entire row N
|
||||
ref.Col = 0
|
||||
} else {
|
||||
col, err := strconv.Atoi(colStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid col %q: %w", colStr, err)
|
||||
}
|
||||
ref.Col = col
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// parseBraceImgRef parses a {img=...} image reference spec.
|
||||
// Supports: {img=1}, {img=-1}, {img=*}, {img=pattern}
|
||||
func parseBraceImgRef(spec string) (*braceImgRef, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, fmt.Errorf("empty image spec")
|
||||
}
|
||||
|
||||
ref := &braceImgRef{}
|
||||
|
||||
// Check for all images
|
||||
if spec == "*" {
|
||||
ref.IsAll = true
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Try to parse as integer (position)
|
||||
if idx, err := strconv.Atoi(spec); err == nil {
|
||||
ref.Index = idx
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Treat as regex pattern for alt text matching
|
||||
regex, err := regexp.Compile(spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid image pattern %q: %w", spec, err)
|
||||
}
|
||||
ref.Pattern = spec
|
||||
ref.Regex = regex
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// detectBracePattern detects if a pattern starts with {T=...} or {img=...}.
|
||||
// Returns the remaining pattern (for cell-level find/replace) and the parsed refs.
|
||||
// For example: "{T=1!A1}old" returns ("old", braceTableRef, nil, nil)
|
||||
func detectBracePattern(pattern string) (remaining string, tableRef *braceTableRef, imgRef *braceImgRef, err error) {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return "", nil, nil, nil
|
||||
}
|
||||
|
||||
// Must start with {
|
||||
if !strings.HasPrefix(pattern, "{") {
|
||||
return pattern, nil, nil, nil
|
||||
}
|
||||
|
||||
// Find closing brace
|
||||
closeIdx := findMatchingBrace(pattern, 0)
|
||||
if closeIdx < 0 {
|
||||
return pattern, nil, nil, nil
|
||||
}
|
||||
|
||||
braceContent := pattern[1:closeIdx]
|
||||
remaining = strings.TrimSpace(pattern[closeIdx+1:])
|
||||
|
||||
// Check for T= (table reference)
|
||||
if strings.HasPrefix(braceContent, "T=") {
|
||||
tableSpec := strings.TrimPrefix(braceContent, "T=")
|
||||
tableRef, err = parseBraceTableRef(tableSpec)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("parse table ref: %w", err)
|
||||
}
|
||||
return remaining, tableRef, nil, nil
|
||||
}
|
||||
|
||||
// Check for img= (image reference)
|
||||
if strings.HasPrefix(braceContent, "img=") {
|
||||
imgSpec := strings.TrimPrefix(braceContent, "img=")
|
||||
imgRef, err = parseBraceImgRef(imgSpec)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("parse image ref: %w", err)
|
||||
}
|
||||
return remaining, nil, imgRef, nil
|
||||
}
|
||||
|
||||
// Not a pattern-side brace ref
|
||||
return pattern, nil, nil, nil
|
||||
}
|
||||
|
||||
// braceTableToSedExpr bridges braceTableRef to the existing sedExpr fields
|
||||
// so existing table machinery (runTableCellReplace, runTableRowColOp, etc.) can handle it.
|
||||
func braceTableToSedExpr(bt *braceTableRef, expr *sedExpr) {
|
||||
if bt == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle table-level reference (no cell spec)
|
||||
if !bt.IsAllCells && !bt.HasRange && bt.Row == 0 && bt.Col == 0 &&
|
||||
bt.RowOp == "" && bt.ColOp == "" && !bt.IsCreate {
|
||||
// Bare table reference: {T=1}, {T=-1}, {T=*}
|
||||
if bt.TableIndex == 0 {
|
||||
// math.MinInt32 signals "all tables" in the existing code
|
||||
expr.tableRef = -2147483648 // math.MinInt32
|
||||
} else {
|
||||
expr.tableRef = bt.TableIndex
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle table creation
|
||||
if bt.IsCreate {
|
||||
// Table creation is handled differently — set a marker
|
||||
// The actual creation happens via replacement parsing
|
||||
return
|
||||
}
|
||||
|
||||
// Build tableCellRef for cell-level operations
|
||||
cellRef := &tableCellRef{
|
||||
tableIndex: bt.TableIndex,
|
||||
}
|
||||
|
||||
// Handle row/col operations
|
||||
if bt.RowOp != "" {
|
||||
cellRef.rowOp, cellRef.opTarget = parseRowColOpValue(bt.RowOp)
|
||||
}
|
||||
if bt.ColOp != "" {
|
||||
cellRef.colOp, cellRef.opTarget = parseRowColOpValue(bt.ColOp)
|
||||
}
|
||||
|
||||
// Handle cell addressing
|
||||
switch {
|
||||
case bt.IsAllCells:
|
||||
// All cells: row=0, col=0 (wildcard in existing code)
|
||||
cellRef.row = 0
|
||||
cellRef.col = 0
|
||||
case bt.RowWild:
|
||||
// Entire row: col=0
|
||||
cellRef.row = bt.Row
|
||||
cellRef.col = 0
|
||||
case bt.ColWild:
|
||||
// Entire column: row=0
|
||||
cellRef.row = 0
|
||||
cellRef.col = bt.Col
|
||||
case bt.HasRange:
|
||||
// Range: set start and end
|
||||
cellRef.row = bt.Row
|
||||
cellRef.col = bt.Col
|
||||
cellRef.endRow = bt.EndRow
|
||||
cellRef.endCol = bt.EndCol
|
||||
default:
|
||||
// Single cell
|
||||
cellRef.row = bt.Row
|
||||
cellRef.col = bt.Col
|
||||
}
|
||||
|
||||
expr.cellRef = cellRef
|
||||
}
|
||||
|
||||
// parseRowColOpValue converts brace op string to existing code's format.
|
||||
// "+2" → ("insert", 2), "$+" → ("append", 0), "2" → ("delete", 2), "-1" → ("delete", -1)
|
||||
func parseRowColOpValue(op string) (operation string, target int) {
|
||||
op = strings.TrimSpace(op)
|
||||
|
||||
if op == "$+" {
|
||||
return opAppend, 0
|
||||
}
|
||||
|
||||
if strings.HasPrefix(op, "+") {
|
||||
n, err := strconv.Atoi(op[1:])
|
||||
if err == nil {
|
||||
return opInsert, n
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// Plain number (positive or negative) = delete
|
||||
n, err := strconv.Atoi(op)
|
||||
if err == nil {
|
||||
return opDelete, n
|
||||
}
|
||||
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// braceImgToImageRefPattern bridges braceImgRef to the existing ImageRefPattern struct.
|
||||
func braceImgToImageRefPattern(bi *braceImgRef) *ImageRefPattern {
|
||||
if bi == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ref := &ImageRefPattern{}
|
||||
|
||||
if bi.IsAll {
|
||||
ref.ByPosition = true
|
||||
ref.AllImages = true
|
||||
return ref
|
||||
}
|
||||
|
||||
if bi.Index != 0 {
|
||||
ref.ByPosition = true
|
||||
ref.Position = bi.Index
|
||||
return ref
|
||||
}
|
||||
|
||||
if bi.Pattern != "" && bi.Regex != nil {
|
||||
ref.ByAlt = true
|
||||
ref.AltRegex = bi.Regex
|
||||
return ref
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// braceTableToTableCreateSpec converts a braceTableRef creation spec to tableCreateSpec.
|
||||
func braceTableToTableCreateSpec(bt *braceTableRef) *tableCreateSpec {
|
||||
if bt == nil || !bt.IsCreate {
|
||||
return nil
|
||||
}
|
||||
return &tableCreateSpec{
|
||||
rows: bt.CreateRows,
|
||||
cols: bt.CreateCols,
|
||||
header: bt.HasHeader,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a debug representation of braceTableRef.
|
||||
func (bt *braceTableRef) String() string {
|
||||
if bt == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
var parts []string
|
||||
|
||||
if bt.IsCreate {
|
||||
parts = append(parts, fmt.Sprintf("create:%dx%d", bt.CreateRows, bt.CreateCols))
|
||||
if bt.HasHeader {
|
||||
parts = append(parts, "header")
|
||||
}
|
||||
return "{T=" + strings.Join(parts, " ") + "}"
|
||||
}
|
||||
|
||||
if bt.TableIndex == 0 {
|
||||
parts = append(parts, "table:*")
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("table:%d", bt.TableIndex))
|
||||
}
|
||||
|
||||
switch {
|
||||
case bt.IsAllCells:
|
||||
parts = append(parts, "cells:*")
|
||||
case bt.HasRange:
|
||||
parts = append(parts, fmt.Sprintf("range:[%d,%d:%d,%d]", bt.Row, bt.Col, bt.EndRow, bt.EndCol))
|
||||
case bt.RowWild:
|
||||
parts = append(parts, fmt.Sprintf("row:%d,*", bt.Row))
|
||||
case bt.ColWild:
|
||||
parts = append(parts, fmt.Sprintf("col:*,%d", bt.Col))
|
||||
case bt.Row > 0 || bt.Col > 0:
|
||||
parts = append(parts, fmt.Sprintf("cell:[%d,%d]", bt.Row, bt.Col))
|
||||
}
|
||||
|
||||
if bt.RowOp != "" {
|
||||
parts = append(parts, "rowOp:"+bt.RowOp)
|
||||
}
|
||||
if bt.ColOp != "" {
|
||||
parts = append(parts, "colOp:"+bt.ColOp)
|
||||
}
|
||||
|
||||
return "{T=" + strings.Join(parts, " ") + "}"
|
||||
}
|
||||
|
||||
// String returns a debug representation of braceImgRef.
|
||||
func (bi *braceImgRef) String() string {
|
||||
if bi == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if bi.IsAll {
|
||||
return "{img=*}"
|
||||
}
|
||||
if bi.Index != 0 {
|
||||
return fmt.Sprintf("{img=%d}", bi.Index)
|
||||
}
|
||||
if bi.Pattern != "" {
|
||||
return fmt.Sprintf("{img=%s}", bi.Pattern)
|
||||
}
|
||||
return "{img=?}"
|
||||
}
|
||||
131
internal/cmd/docs_sed_brace_resolve.go
Normal file
131
internal/cmd/docs_sed_brace_resolve.go
Normal file
@ -0,0 +1,131 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// namedColors maps color names to hex values per SEDMAT spec.
|
||||
var namedColors = map[string]string{
|
||||
"black": "#000000",
|
||||
"white": "#FFFFFF",
|
||||
"red": "#FF0000",
|
||||
"green": "#00FF00",
|
||||
"blue": "#0000FF",
|
||||
"yellow": "#FFFF00",
|
||||
"cyan": "#00FFFF",
|
||||
"magenta": "#FF00FF",
|
||||
"orange": "#FF8C00",
|
||||
"purple": "#800080",
|
||||
"pink": "#FF69B4",
|
||||
"brown": "#8B4513",
|
||||
"gray": "#808080",
|
||||
"grey": "#808080",
|
||||
"lightgray": "#D3D3D3",
|
||||
"darkgray": "#404040",
|
||||
"navy": "#000080",
|
||||
"teal": "#008080",
|
||||
}
|
||||
|
||||
|
||||
// resolveColor returns hex from a color name or passes through hex values.
|
||||
// If the input is a named color, returns its hex equivalent.
|
||||
// If already hex (#RRGGBB), returns as-is.
|
||||
// Otherwise returns the input unchanged.
|
||||
func resolveColor(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
if hex, ok := namedColors[lower]; ok {
|
||||
return hex
|
||||
}
|
||||
// Already hex or unknown — return as-is
|
||||
return s
|
||||
}
|
||||
|
||||
// headingMap maps SEDMAT heading values to Google Docs named styles.
|
||||
var headingMap = map[string]string{
|
||||
"t": "TITLE",
|
||||
"s": "SUBTITLE",
|
||||
"1": "HEADING_1",
|
||||
"2": "HEADING_2",
|
||||
"3": "HEADING_3",
|
||||
"4": "HEADING_4",
|
||||
"5": "HEADING_5",
|
||||
"6": "HEADING_6",
|
||||
"0": "NORMAL_TEXT",
|
||||
}
|
||||
|
||||
// resolveHeading converts SEDMAT heading shorthand to Google Docs named style.
|
||||
func resolveHeading(h string) string {
|
||||
if mapped, ok := headingMap[h]; ok {
|
||||
return mapped
|
||||
}
|
||||
// Check for numeric string
|
||||
if len(h) == 1 && h[0] >= '1' && h[0] <= '6' {
|
||||
return fmt.Sprintf("HEADING_%s", h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
// alignMap maps SEDMAT alignment values to Google Docs alignment constants.
|
||||
var alignMap = map[string]string{
|
||||
"left": "START",
|
||||
"center": "CENTER",
|
||||
"right": "END",
|
||||
"justify": "JUSTIFIED",
|
||||
}
|
||||
|
||||
// resolveAlign converts SEDMAT alignment shorthand to Google Docs alignment.
|
||||
func resolveAlign(a string) string {
|
||||
if mapped, ok := alignMap[strings.ToLower(a)]; ok {
|
||||
return mapped
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// breakMap maps SEDMAT break values to descriptions.
|
||||
var breakMap = map[string]string{
|
||||
"": "horizontal_rule",
|
||||
"p": "page_break",
|
||||
"c": "column_break",
|
||||
"s": "section_break",
|
||||
}
|
||||
|
||||
// resolveBreak converts SEDMAT break shorthand to a descriptive string.
|
||||
func resolveBreak(b string) string {
|
||||
if mapped, ok := breakMap[b]; ok {
|
||||
return mapped
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// isHexColor returns true if s looks like a valid hex color (#RRGGBB or #RGB).
|
||||
func isHexColor(s string) bool {
|
||||
if !strings.HasPrefix(s, "#") {
|
||||
return false
|
||||
}
|
||||
s = s[1:]
|
||||
if len(s) != 3 && len(s) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !unicode.Is(unicode.ASCII_Hex_Digit, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeHexColor converts #RGB to #RRGGBB format and uppercases.
|
||||
func normalizeHexColor(s string) string {
|
||||
if !strings.HasPrefix(s, "#") {
|
||||
return s
|
||||
}
|
||||
hex := strings.ToUpper(s[1:])
|
||||
if len(hex) == 3 {
|
||||
// Expand #RGB to #RRGGBB
|
||||
return fmt.Sprintf("#%c%c%c%c%c%c", hex[0], hex[0], hex[1], hex[1], hex[2], hex[2])
|
||||
}
|
||||
return "#" + hex
|
||||
}
|
||||
509
internal/cmd/docs_sed_brace_structural.go
Normal file
509
internal/cmd/docs_sed_brace_structural.go
Normal file
@ -0,0 +1,509 @@
|
||||
// Package cmd provides CLI commands for Google Docs operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
)
|
||||
|
||||
// buildColumnsRequest creates UpdateSectionStyleRequest to set column count.
|
||||
// The range should span the section containing the match.
|
||||
func buildColumnsRequest(be *braceExpr, sectionStart, sectionEnd int64) []*docs.Request {
|
||||
if be == nil || be.Cols <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build column properties array — one per column with equal width
|
||||
// Google Docs handles equal distribution automatically when widths not specified
|
||||
// Google Docs requires padding on each column property.
|
||||
// Default 36pt (0.5in) gap between columns.
|
||||
var colProps []*docs.SectionColumnProperties
|
||||
for i := 0; i < be.Cols; i++ {
|
||||
colProps = append(colProps, &docs.SectionColumnProperties{
|
||||
PaddingEnd: &docs.Dimension{Magnitude: 36, Unit: "PT"},
|
||||
})
|
||||
}
|
||||
|
||||
return []*docs.Request{
|
||||
{
|
||||
UpdateSectionStyle: &docs.UpdateSectionStyleRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: sectionStart,
|
||||
EndIndex: sectionEnd,
|
||||
},
|
||||
SectionStyle: &docs.SectionStyle{
|
||||
ColumnProperties: colProps,
|
||||
ColumnSeparatorStyle: "NONE",
|
||||
},
|
||||
Fields: "columnProperties,columnSeparatorStyle",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildCheckboxRequests creates a checklist paragraph using BULLET_CHECKBOX preset.
|
||||
// For {check=y}, the checked state cannot be toggled via API — Google Docs API
|
||||
// creates unchecked checkboxes only. Users must manually check them.
|
||||
func buildCheckboxRequests(be *braceExpr, start, end int64) []*docs.Request {
|
||||
if be == nil || be.Check == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateParagraphBullets with BULLET_CHECKBOX preset creates checkbox lists.
|
||||
// Note: The API does not support setting the checked state programmatically.
|
||||
// All checkboxes are created unchecked; {check=y} is documented as a limitation.
|
||||
return []*docs.Request{
|
||||
{
|
||||
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end + 1},
|
||||
BulletPreset: "BULLET_CHECKBOX",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildTOCRequest creates requests to insert a Table of Contents.
|
||||
// Google Docs API does NOT support InsertTableOfContents via batchUpdate.
|
||||
// This is a documented API limitation — TOC must be inserted manually via the UI.
|
||||
//
|
||||
// TODO: When Google adds InsertTableOfContentsRequest to the API, implement it here.
|
||||
// For now, this function returns nil and the limitation is documented.
|
||||
func buildTOCRequest(be *braceExpr, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support
|
||||
if be == nil || !be.HasTOC {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Google Docs API limitation: No InsertTableOfContentsRequest exists.
|
||||
// The API can read TOC elements but cannot create them programmatically.
|
||||
// Return nil and document as unsupported.
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCommentRequest creates requests to add a comment/annotation to text.
|
||||
// Google Docs batchUpdate API does NOT support creating comments.
|
||||
// Comments must be created via the Drive Comments API (drive.comments.create).
|
||||
//
|
||||
// TODO: Implement via Drive API separately if needed.
|
||||
// For now, this function returns nil and the limitation is documented.
|
||||
func buildCommentRequest(be *braceExpr, _, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support
|
||||
if be == nil || be.Comment == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Google Docs API limitation: Comments are not supported in batchUpdate.
|
||||
// The Drive API (v3) supports comments via drive.comments.create,
|
||||
// but that requires a separate API call outside of batchUpdate.
|
||||
// Return nil and document as unsupported in this flow.
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBookmarkRequest creates a NamedRange (bookmark) at the matched text.
|
||||
// Bookmarks can be linked via {u=#name} syntax.
|
||||
func buildBookmarkRequest(be *braceExpr, start, end int64) []*docs.Request {
|
||||
if be == nil || be.Bookmark == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*docs.Request{
|
||||
{
|
||||
CreateNamedRange: &docs.CreateNamedRangeRequest{
|
||||
Name: be.Bookmark,
|
||||
Range: &docs.Range{
|
||||
StartIndex: start,
|
||||
EndIndex: end,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildPersonChipRequest creates an InsertPerson request for person smart chips.
|
||||
// Syntax: chip://person/email@example.com
|
||||
func buildPersonChipRequest(email string, index int64) []*docs.Request {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*docs.Request{
|
||||
{
|
||||
InsertPerson: &docs.InsertPersonRequest{
|
||||
Location: &docs.Location{Index: index},
|
||||
PersonProperties: &docs.PersonProperties{
|
||||
Email: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ChipType represents the type of smart chip to insert.
|
||||
type ChipType int
|
||||
|
||||
const (
|
||||
ChipTypeUnknown ChipType = iota
|
||||
ChipTypePerson
|
||||
ChipTypeDate
|
||||
ChipTypeFile
|
||||
ChipTypePlace
|
||||
ChipTypeDropdown
|
||||
ChipTypeChart
|
||||
ChipTypeBookmark
|
||||
)
|
||||
|
||||
// ChipSpec holds parsed smart chip specification.
|
||||
type ChipSpec struct {
|
||||
Type ChipType
|
||||
Value string // email for person, date string for date, etc.
|
||||
Options []string // dropdown options
|
||||
}
|
||||
|
||||
// parseChipURI parses a chip:// URI into a ChipSpec.
|
||||
// Supported formats:
|
||||
// - chip://person/email@example.com
|
||||
// - chip://date/2026-03-15
|
||||
// - chip://file/DOC_ID
|
||||
// - chip://place/Orlando, FL
|
||||
// - chip://dropdown/Draft|Review|Done
|
||||
// - chip://bookmark/section-1
|
||||
func parseChipURI(uri string) *ChipSpec {
|
||||
if !strings.HasPrefix(uri, "chip://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove prefix
|
||||
path := uri[7:] // "chip://" is 7 chars
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chipType := strings.ToLower(parts[0])
|
||||
value := parts[1]
|
||||
|
||||
spec := &ChipSpec{Value: value}
|
||||
|
||||
switch chipType {
|
||||
case "person":
|
||||
spec.Type = ChipTypePerson
|
||||
case "date":
|
||||
spec.Type = ChipTypeDate
|
||||
case strFile:
|
||||
spec.Type = ChipTypeFile
|
||||
case "place":
|
||||
spec.Type = ChipTypePlace
|
||||
case "dropdown":
|
||||
spec.Type = ChipTypeDropdown
|
||||
// Parse pipe-separated options
|
||||
spec.Options = strings.Split(value, "|")
|
||||
case "chart":
|
||||
spec.Type = ChipTypeChart
|
||||
case "bookmark":
|
||||
spec.Type = ChipTypeBookmark
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
// buildChipRequests creates requests for smart chip insertion based on chip:// URI.
|
||||
// Supported chips:
|
||||
// - person: Uses InsertPerson API (fully supported)
|
||||
// - bookmark: Uses Link with BookmarkId (fully supported)
|
||||
//
|
||||
// Limited/unsupported chips (documented as API limitations):
|
||||
// - date: No InsertDate request in API
|
||||
// - file: RichLink is read-only
|
||||
// - place: No InsertPlace request in API
|
||||
// - dropdown: No InsertDropdown request in API
|
||||
// - chart: Use InsertInlineSheetsChart (requires sheetId, separate handling)
|
||||
func buildChipRequests(be *braceExpr, index int64) []*docs.Request {
|
||||
if be == nil || be.URL == "" || !strings.HasPrefix(be.URL, "chip://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
chip := parseChipURI(be.URL)
|
||||
if chip == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch chip.Type {
|
||||
case ChipTypePerson:
|
||||
return buildPersonChipRequest(chip.Value, index)
|
||||
|
||||
case ChipTypeBookmark:
|
||||
// Bookmark chip is handled via link with BookmarkId
|
||||
// This is covered in buildBraceTextStyleRequests via {u=#name}
|
||||
// We convert chip://bookmark/name to #name for consistency
|
||||
return nil
|
||||
|
||||
case ChipTypeDate:
|
||||
// Google Docs API limitation: No InsertDate request.
|
||||
// Date chips cannot be created programmatically.
|
||||
// TODO: When API support is added, implement here.
|
||||
return nil
|
||||
|
||||
case ChipTypeFile:
|
||||
// Google Docs API limitation: RichLink is read-only.
|
||||
// File chips cannot be created via batchUpdate.
|
||||
// Workaround: Insert a regular hyperlink to the Drive file.
|
||||
return nil
|
||||
|
||||
case ChipTypePlace:
|
||||
// Google Docs API limitation: No InsertPlace request.
|
||||
// Place chips cannot be created programmatically.
|
||||
return nil
|
||||
|
||||
case ChipTypeDropdown:
|
||||
// Google Docs API limitation: No InsertDropdown request.
|
||||
// Dropdown chips cannot be created programmatically.
|
||||
return nil
|
||||
|
||||
case ChipTypeChart:
|
||||
// InsertInlineSheetsChart exists but requires sheetId and chartId.
|
||||
// Parse format: chip://chart/SHEET_ID/CHART_INDEX
|
||||
// This is complex and handled separately in docs_sed_image.go
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasBraceStructuralFeatures returns true if the braceExpr has any structural features
|
||||
// that require special handling beyond text/paragraph formatting.
|
||||
func hasBraceStructuralFeatures(be *braceExpr) bool {
|
||||
if be == nil {
|
||||
return false
|
||||
}
|
||||
return be.Cols > 0 ||
|
||||
be.Check != nil ||
|
||||
be.HasTOC ||
|
||||
be.Comment != "" ||
|
||||
be.Bookmark != "" ||
|
||||
(be.URL != "" && strings.HasPrefix(be.URL, "chip://"))
|
||||
}
|
||||
|
||||
// buildStructuralRequests builds all structural requests for a braceExpr.
|
||||
// Returns:
|
||||
// - columnReqs: requests that modify section style (must be applied to sections)
|
||||
// - bulletReqs: checkbox/bullet requests (must be applied after text insertion)
|
||||
// - anchorReqs: bookmark/named range requests (must be applied to text ranges)
|
||||
// - chipReqs: smart chip requests (must be applied at indices)
|
||||
func buildStructuralRequests(
|
||||
be *braceExpr,
|
||||
textStart, textEnd int64,
|
||||
sectionStart, sectionEnd int64,
|
||||
) (columnReqs, bulletReqs, anchorReqs, chipReqs []*docs.Request) {
|
||||
if be == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
// Columns (section-level)
|
||||
if be.Cols > 0 {
|
||||
columnReqs = buildColumnsRequest(be, sectionStart, sectionEnd)
|
||||
}
|
||||
|
||||
// Checkboxes (paragraph-level bullets)
|
||||
if be.Check != nil {
|
||||
bulletReqs = buildCheckboxRequests(be, textStart, textEnd)
|
||||
}
|
||||
|
||||
// Bookmarks (text-level anchors)
|
||||
if be.Bookmark != "" {
|
||||
anchorReqs = buildBookmarkRequest(be, textStart, textEnd)
|
||||
}
|
||||
|
||||
// Smart chips
|
||||
if be.URL != "" && strings.HasPrefix(be.URL, "chip://") {
|
||||
chipReqs = buildChipRequests(be, textStart)
|
||||
}
|
||||
|
||||
return columnReqs, bulletReqs, anchorReqs, chipReqs
|
||||
}
|
||||
|
||||
// parseDate parses a date string into components.
|
||||
// Supports: YYYY-MM-DD, YYYY/MM/DD, MM-DD-YYYY, MM/DD/YYYY
|
||||
func parseDate(dateStr string) (year, month, day int, ok bool) {
|
||||
// Try standard formats
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"01-02-2006",
|
||||
"01/02/2006",
|
||||
"January 2, 2006",
|
||||
"Jan 2, 2006",
|
||||
}
|
||||
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, dateStr); err == nil {
|
||||
return t.Year(), int(t.Month()), t.Day(), true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
// buildDateFallbackText creates a formatted date string for fallback display
|
||||
// when native date chips are not available.
|
||||
func buildDateFallbackText(dateStr string) string {
|
||||
if year, month, day, ok := parseDate(dateStr); ok {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", year, month, day)
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
// resolveChipURL processes chip:// URLs and returns either:
|
||||
// - The original URL for non-chip URLs
|
||||
// - A fallback URL for chips that can be approximated with links
|
||||
// - Empty string for chips that have no link fallback
|
||||
func resolveChipURL(chipURL string) (resolvedURL string, fallbackText string) {
|
||||
if !strings.HasPrefix(chipURL, "chip://") {
|
||||
return chipURL, ""
|
||||
}
|
||||
|
||||
chip := parseChipURI(chipURL)
|
||||
if chip == nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
switch chip.Type {
|
||||
case ChipTypePerson:
|
||||
// Person chips are handled natively via InsertPerson
|
||||
return "", ""
|
||||
|
||||
case ChipTypeBookmark:
|
||||
// Convert to bookmark link
|
||||
return "#" + chip.Value, ""
|
||||
|
||||
case ChipTypeDate:
|
||||
// No link fallback for dates
|
||||
return "", buildDateFallbackText(chip.Value)
|
||||
|
||||
case ChipTypeFile:
|
||||
// Convert to Drive file link
|
||||
return "https://docs.google.com/document/d/" + chip.Value, ""
|
||||
|
||||
case ChipTypePlace:
|
||||
// Convert to Google Maps link
|
||||
return "https://maps.google.com/?q=" + url.QueryEscape(chip.Value), chip.Value
|
||||
|
||||
case ChipTypeDropdown:
|
||||
// No link fallback, return options as text
|
||||
return "", strings.Join(chip.Options, " / ")
|
||||
|
||||
case ChipTypeChart:
|
||||
// Charts need separate handling via InsertInlineSheetsChart
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// getCheckboxState returns a human-readable checkbox state description.
|
||||
func getCheckboxState(be *braceExpr) string {
|
||||
if be == nil || be.Check == nil {
|
||||
return ""
|
||||
}
|
||||
if *be.Check {
|
||||
return "checked"
|
||||
}
|
||||
return "unchecked"
|
||||
}
|
||||
|
||||
// buildSectionRangeForMatch finds the section boundaries containing a match.
|
||||
// In Google Docs, sections are delimited by SectionBreak elements.
|
||||
// If the document has no section breaks, the entire body is one section.
|
||||
func buildSectionRangeForMatch(doc *docs.Document, matchStart, matchEnd int64) (sectionStart, sectionEnd int64) {
|
||||
if doc == nil || doc.Body == nil {
|
||||
return 1, matchEnd + 1
|
||||
}
|
||||
|
||||
// Find section boundaries by scanning for SectionBreak elements
|
||||
sectionStart = 1 // Body starts at index 1
|
||||
sectionEnd = matchEnd + 1
|
||||
|
||||
// Track the last section break we passed
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.SectionBreak != nil {
|
||||
// If we've passed this section break and it's before the match
|
||||
if elem.EndIndex <= matchStart {
|
||||
sectionStart = elem.EndIndex
|
||||
}
|
||||
// If this section break is after the match, it's the end boundary
|
||||
if elem.StartIndex > matchEnd && sectionEnd == matchEnd+1 {
|
||||
sectionEnd = elem.StartIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
// Update sectionEnd to the last element's end index
|
||||
if elem.EndIndex > sectionEnd {
|
||||
sectionEnd = elem.EndIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure valid range
|
||||
if sectionEnd <= sectionStart {
|
||||
sectionEnd = sectionStart + 1
|
||||
}
|
||||
|
||||
return sectionStart, sectionEnd
|
||||
}
|
||||
|
||||
// stripChipPrefix removes the chip:// prefix and type from a URL
|
||||
// and returns just the value portion. Used for fallback text display.
|
||||
func stripChipPrefix(uri string) string {
|
||||
if !strings.HasPrefix(uri, "chip://") {
|
||||
return uri
|
||||
}
|
||||
|
||||
path := uri[7:]
|
||||
if idx := strings.Index(path, "/"); idx >= 0 {
|
||||
return path[idx+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isPersonChip returns true if the URL is a person chip.
|
||||
func isPersonChip(uri string) bool {
|
||||
return strings.HasPrefix(uri, "chip://person/")
|
||||
}
|
||||
|
||||
// extractPersonEmail extracts the email from a person chip URL.
|
||||
func extractPersonEmail(uri string) string {
|
||||
if !isPersonChip(uri) {
|
||||
return ""
|
||||
}
|
||||
return uri[14:] // len("chip://person/") = 14
|
||||
}
|
||||
|
||||
// parseChartChip parses a chart chip URL.
|
||||
// Format: chip://chart/SHEET_ID/CHART_INDEX
|
||||
// Returns sheetId and chartIndex (0-based).
|
||||
func parseChartChip(uri string) (sheetID string, chartIndex int, ok bool) {
|
||||
if !strings.HasPrefix(uri, "chip://chart/") {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
path := uri[13:] // len("chip://chart/") = 13
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
sheetID = parts[0]
|
||||
idx, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
return sheetID, idx, true
|
||||
}
|
||||
|
||||
// TODO: Implement full chart insertion in docs_sed_image.go using:
|
||||
// InsertInlineSheetsChart (need to check if this request type exists)
|
||||
// For now, chart chips are not supported via this path.
|
||||
222
internal/cmd/docs_sed_commands.go
Normal file
222
internal/cmd/docs_sed_commands.go
Normal file
@ -0,0 +1,222 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// fetchDoc creates a Docs service and fetches the document. Used by command implementations
|
||||
// that need the full document structure (delete, append, insert).
|
||||
func fetchDoc(ctx context.Context, account, id string) (*docs.Service, *docs.Document, error) {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
doc, err := getDoc(ctx, docsSvc, id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
return docsSvc, doc, nil
|
||||
}
|
||||
|
||||
// runDeleteCommand executes a d/pattern/ command, deleting all lines containing the pattern.
|
||||
func (c *DocsSedCmd) runDeleteCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
re, err := expr.compilePattern()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile pattern: %w", err)
|
||||
}
|
||||
|
||||
// Find paragraphs matching the pattern and collect their ranges for deletion
|
||||
var requests []*docs.Request
|
||||
deleted := 0
|
||||
|
||||
// Walk in reverse so deletions don't shift indices
|
||||
if doc.Body == nil {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0 (empty document)"})
|
||||
}
|
||||
elems := doc.Body.Content
|
||||
for i := len(elems) - 1; i >= 0; i-- {
|
||||
elem := elems[i]
|
||||
if elem.Paragraph == nil {
|
||||
continue
|
||||
}
|
||||
text := extractParagraphText(elem.Paragraph)
|
||||
if re.MatchString(text) {
|
||||
start := elem.StartIndex
|
||||
end := elem.EndIndex
|
||||
// Don't delete before the document body start
|
||||
if start < 1 {
|
||||
start = 1
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: start,
|
||||
EndIndex: end,
|
||||
SegmentId: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0 (no matches)"})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (delete): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: fmt.Sprintf("%d lines", deleted)})
|
||||
}
|
||||
|
||||
// runAppendCommand executes an a/pattern/text/ command, inserting text after each matching line.
|
||||
func (c *DocsSedCmd) runAppendCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
return c.runInsertAroundMatch(ctx, u, account, id, expr, false)
|
||||
}
|
||||
|
||||
// runInsertCommand executes an i/pattern/text/ command, inserting text before each matching line.
|
||||
func (c *DocsSedCmd) runInsertCommand(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
return c.runInsertAroundMatch(ctx, u, account, id, expr, true)
|
||||
}
|
||||
|
||||
// runInsertAroundMatch implements both append-after and insert-before matching lines.
|
||||
func (c *DocsSedCmd) runInsertAroundMatch(ctx context.Context, u *ui.UI, account, id string, expr sedExpr, before bool) error {
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
re, err := expr.compilePattern()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile pattern: %w", err)
|
||||
}
|
||||
|
||||
// Process replacement text: convert \n to real newlines
|
||||
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
|
||||
if !strings.HasSuffix(insertText, "\n") {
|
||||
insertText += "\n"
|
||||
}
|
||||
|
||||
// Collect insertion points (in reverse order to preserve indices)
|
||||
var insertPoints []int64
|
||||
if doc.Body == nil {
|
||||
cmd := "appended"
|
||||
if before {
|
||||
cmd = "inserted"
|
||||
}
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, Value: "0 (empty document)"})
|
||||
}
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.Paragraph == nil {
|
||||
continue
|
||||
}
|
||||
text := extractParagraphText(elem.Paragraph)
|
||||
if re.MatchString(text) {
|
||||
if before {
|
||||
insertPoints = append(insertPoints, elem.StartIndex)
|
||||
} else {
|
||||
insertPoints = append(insertPoints, elem.EndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(insertPoints) == 0 {
|
||||
cmd := "appended"
|
||||
if before {
|
||||
cmd = "inserted"
|
||||
}
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, Value: "0 (no matches)"})
|
||||
}
|
||||
|
||||
// Build requests in reverse document order
|
||||
var requests []*docs.Request
|
||||
for i := len(insertPoints) - 1; i >= 0; i-- {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: insertPoints[i]},
|
||||
Text: insertText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (insert): %w", err)
|
||||
}
|
||||
|
||||
cmd := "appended"
|
||||
if before {
|
||||
cmd = "inserted"
|
||||
}
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: cmd, Value: fmt.Sprintf("%d lines", len(insertPoints))})
|
||||
}
|
||||
|
||||
// runTransliterate executes a y/source/dest/ command, replacing each character in source
|
||||
// with the corresponding character in dest throughout the document.
|
||||
func (c *DocsSedCmd) runTransliterate(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
docsSvc, _, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceRunes := []rune(expr.pattern)
|
||||
destRunes := []rune(expr.replacement)
|
||||
|
||||
// Use native FindReplace for each character pair
|
||||
var requests []*docs.Request
|
||||
for i, src := range sourceRunes {
|
||||
requests = append(requests, &docs.Request{
|
||||
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
||||
ContainsText: &docs.SubstringMatchCriteria{
|
||||
Text: string(src),
|
||||
MatchCase: true,
|
||||
},
|
||||
ReplaceText: string(destRunes[i]),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := batchUpdate(ctx, docsSvc, id, requests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (transliterate): %w", err)
|
||||
}
|
||||
var replaced int
|
||||
if resp != nil {
|
||||
for _, reply := range resp.Replies {
|
||||
if reply.ReplaceAllText != nil {
|
||||
replaced += int(reply.ReplaceAllText.OccurrencesChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id,
|
||||
sedOutputKV{Key: "transliterated", Value: fmt.Sprintf("%d chars across %d pairs", replaced, len(sourceRunes))},
|
||||
)
|
||||
}
|
||||
|
||||
// extractParagraphText returns the plain text content of a paragraph.
|
||||
func extractParagraphText(p *docs.Paragraph) string {
|
||||
// Fast path: single text run (most common case) avoids Builder allocation.
|
||||
if len(p.Elements) == 1 && p.Elements[0].TextRun != nil {
|
||||
return strings.TrimRight(p.Elements[0].TextRun.Content, "\n")
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, elem := range p.Elements {
|
||||
if elem.TextRun != nil {
|
||||
sb.WriteString(elem.TextRun.Content)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
198
internal/cmd/docs_sed_dryrun.go
Normal file
198
internal/cmd/docs_sed_dryrun.go
Normal file
@ -0,0 +1,198 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// runDryRun validates and classifies expressions without making API calls.
|
||||
// It prints a summary of each expression's type, validity, and content.
|
||||
// No authentication is required since no Google API calls are made.
|
||||
func (c *DocsSedCmd) runDryRun(_ context.Context, u *ui.UI, exprs []sedExpr) error {
|
||||
for i, expr := range exprs {
|
||||
kind := classifyExpression(expr)
|
||||
|
||||
valid := "ok"
|
||||
if expr.pattern != "" && expr.pattern != "^" && expr.pattern != "$" && expr.pattern != "^$" {
|
||||
if _, err := expr.compilePattern(); err != nil {
|
||||
valid = fmt.Sprintf("ERROR: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
flag := ""
|
||||
if expr.global {
|
||||
flag = "g"
|
||||
}
|
||||
|
||||
prefix := "s"
|
||||
if expr.command != 0 {
|
||||
prefix = string(expr.command)
|
||||
}
|
||||
nthStr := ""
|
||||
if expr.nthMatch > 0 {
|
||||
nthStr = fmt.Sprintf("%d", expr.nthMatch)
|
||||
}
|
||||
|
||||
// Include brace flags in output if present
|
||||
braceInfo := ""
|
||||
if expr.brace != nil {
|
||||
braceInfo = formatBraceFlags(expr.brace)
|
||||
}
|
||||
|
||||
if braceInfo != "" {
|
||||
u.Out().Printf("%d\t%s\t%s\t%s/%s/%s/%s%s\t%s", i+1, kind, valid, prefix, expr.pattern, truncateSed(expr.replacement, 40), flag, nthStr, braceInfo)
|
||||
} else {
|
||||
u.Out().Printf("%d\t%s\t%s\t%s/%s/%s/%s%s", i+1, kind, valid, prefix, expr.pattern, truncateSed(expr.replacement, 40), flag, nthStr)
|
||||
}
|
||||
}
|
||||
|
||||
u.Out().Printf("---")
|
||||
u.Out().Printf("dry-run: %d expressions parsed, no changes made", len(exprs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyExpression determines the type of a sed expression for dry-run display.
|
||||
func classifyExpression(expr sedExpr) string {
|
||||
switch expr.command {
|
||||
case 'd':
|
||||
return "delete"
|
||||
case 'a':
|
||||
return "append-after"
|
||||
case 'i':
|
||||
return "insert-before"
|
||||
case 'y':
|
||||
return "transliterate"
|
||||
}
|
||||
if expr.cellRef != nil {
|
||||
kind := fmt.Sprintf("cell |%d|[%d,%d]", expr.cellRef.tableIndex, expr.cellRef.row, expr.cellRef.col)
|
||||
if expr.cellRef.row == 0 || expr.cellRef.col == 0 {
|
||||
kind += " (wildcard)"
|
||||
}
|
||||
return kind
|
||||
}
|
||||
if expr.tableRef != 0 {
|
||||
if expr.replacement == "" {
|
||||
return fmt.Sprintf("delete table %d", expr.tableRef)
|
||||
}
|
||||
return fmt.Sprintf("table %d op", expr.tableRef)
|
||||
}
|
||||
if parseTableCreate(expr.replacement) != nil || parseTableFromPipes(expr.replacement) != nil {
|
||||
return "create table"
|
||||
}
|
||||
if parseImageRefPattern(expr.pattern) != nil {
|
||||
return "image"
|
||||
}
|
||||
if expr.pattern == "^" || expr.pattern == "$" || expr.pattern == "^$" {
|
||||
return "positional"
|
||||
}
|
||||
// Check for brace formatting
|
||||
if expr.brace != nil && braceExprHasAnyFormat(expr.brace) {
|
||||
return "brace"
|
||||
}
|
||||
if canUseNativeReplace(expr.replacement) && expr.global && expr.brace == nil {
|
||||
return "native"
|
||||
}
|
||||
return "manual"
|
||||
}
|
||||
|
||||
// formatBraceFlags returns a compact string representation of brace flags for display.
|
||||
func formatBraceFlags(be *braceExpr) string {
|
||||
if be == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Reset
|
||||
if be.Reset {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
|
||||
// Boolean flags
|
||||
if be.Bold != nil {
|
||||
if *be.Bold {
|
||||
parts = append(parts, "b")
|
||||
} else {
|
||||
parts = append(parts, "!b")
|
||||
}
|
||||
}
|
||||
if be.Italic != nil {
|
||||
if *be.Italic {
|
||||
parts = append(parts, "i")
|
||||
} else {
|
||||
parts = append(parts, "!i")
|
||||
}
|
||||
}
|
||||
if be.Underline != nil {
|
||||
if *be.Underline {
|
||||
parts = append(parts, "_")
|
||||
} else {
|
||||
parts = append(parts, "!_")
|
||||
}
|
||||
}
|
||||
if be.Strike != nil {
|
||||
if *be.Strike {
|
||||
parts = append(parts, "-")
|
||||
} else {
|
||||
parts = append(parts, "!-")
|
||||
}
|
||||
}
|
||||
if be.Code != nil && *be.Code {
|
||||
parts = append(parts, "#")
|
||||
}
|
||||
if be.Sup != nil && *be.Sup {
|
||||
parts = append(parts, "^")
|
||||
}
|
||||
if be.Sub != nil && *be.Sub {
|
||||
parts = append(parts, ",")
|
||||
}
|
||||
if be.SmallCaps != nil && *be.SmallCaps {
|
||||
parts = append(parts, "w")
|
||||
}
|
||||
|
||||
// Value flags
|
||||
if be.Color != "" {
|
||||
parts = append(parts, "c="+be.Color)
|
||||
}
|
||||
if be.Bg != "" {
|
||||
parts = append(parts, "z="+be.Bg)
|
||||
}
|
||||
if be.Font != "" {
|
||||
parts = append(parts, "f="+be.Font)
|
||||
}
|
||||
if be.Size > 0 {
|
||||
parts = append(parts, fmt.Sprintf("s=%.0f", be.Size))
|
||||
}
|
||||
if be.URL != "" {
|
||||
parts = append(parts, "u="+truncateSed(be.URL, 20))
|
||||
}
|
||||
if be.Heading != "" {
|
||||
parts = append(parts, "h="+be.Heading)
|
||||
}
|
||||
if be.Align != "" {
|
||||
parts = append(parts, "a="+be.Align)
|
||||
}
|
||||
if be.HasBreak {
|
||||
if be.Break == "" {
|
||||
parts = append(parts, "+")
|
||||
} else {
|
||||
parts = append(parts, "+="+be.Break)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "{" + strings.Join(parts, " ") + "}"
|
||||
}
|
||||
|
||||
// truncateSed shortens a string to max characters, appending "..." if truncated.
|
||||
func truncateSed(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
470
internal/cmd/docs_sed_helpers.go
Normal file
470
internal/cmd/docs_sed_helpers.go
Normal file
@ -0,0 +1,470 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// compilePattern compiles the sedExpr's pattern into a regexp.
|
||||
func (e *sedExpr) compilePattern() (*regexp.Regexp, error) {
|
||||
return regexp.Compile(e.pattern)
|
||||
}
|
||||
|
||||
// batchUpdate executes a Documents.BatchUpdate with retry-on-quota.
|
||||
// Returns the response (may be nil on success with no replies).
|
||||
func batchUpdate(ctx context.Context, docsSvc *docs.Service, docID string, reqs []*docs.Request) (*docs.BatchUpdateDocumentResponse, error) {
|
||||
if len(reqs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var resp *docs.BatchUpdateDocumentResponse
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
resp, e = docsSvc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: reqs,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// getDoc fetches a document with retry-on-quota.
|
||||
func getDoc(ctx context.Context, docsSvc *docs.Service, docID string) (*docs.Document, error) {
|
||||
var doc *docs.Document
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(docID).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
return doc, err
|
||||
}
|
||||
|
||||
// codeBackgroundGrey is the RGB value for inline code background shading.
|
||||
const codeBackgroundGrey = 0.95
|
||||
|
||||
// borderGrey is the grey intensity for borders (blockquotes, horizontal rules).
|
||||
const borderGrey = 0.8
|
||||
|
||||
// indentPointsPerLevel is the number of points per indent level in Google Docs.
|
||||
const indentPointsPerLevel = 36.0
|
||||
|
||||
// hrulePaddingPt is the padding in points below a horizontal rule border.
|
||||
const hrulePaddingPt = 6.0
|
||||
|
||||
// blockquoteBorderWidthPt is the border width in points for blockquotes.
|
||||
const blockquoteBorderWidthPt = 3.0
|
||||
|
||||
// blockquoteIndentPt is the left indent in points for blockquotes.
|
||||
const blockquoteIndentPt = 36.0
|
||||
|
||||
// blockquotePaddingPt is the left padding in points for blockquotes.
|
||||
const blockquotePaddingPt = 12.0
|
||||
|
||||
// bulletPresetDisc is the default unordered bullet preset.
|
||||
const bulletPresetDisc = "BULLET_DISC_CIRCLE_SQUARE"
|
||||
|
||||
// Table cell operation constants.
|
||||
const (
|
||||
opAppend = "append"
|
||||
opInsert = "insert"
|
||||
opDelete = "delete"
|
||||
)
|
||||
|
||||
// Table merge/split operation constants.
|
||||
const (
|
||||
mergeOp = "merge"
|
||||
unmergeOp = "unmerge"
|
||||
splitOp = "split"
|
||||
)
|
||||
|
||||
// buildImageSizeSpec returns a Size for the image spec, or nil if no dimensions set.
|
||||
func buildImageSizeSpec(spec *ImageSpec) *docs.Size {
|
||||
if spec.Width == 0 && spec.Height == 0 {
|
||||
return nil
|
||||
}
|
||||
size := &docs.Size{}
|
||||
if spec.Width > 0 {
|
||||
size.Width = &docs.Dimension{Magnitude: float64(spec.Width), Unit: "PT"}
|
||||
}
|
||||
if spec.Height > 0 {
|
||||
size.Height = &docs.Dimension{Magnitude: float64(spec.Height), Unit: "PT"}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// buildCellReplaceRequests builds delete+insert+format requests for replacing table cell content.
|
||||
// If deleteEnd > startIdx, deletes old content. Inserts plainText at startIdx and applies formats.
|
||||
func buildCellReplaceRequests(startIdx, deleteEnd int64, plainText string, formats []string) []*docs.Request {
|
||||
var requests []*docs.Request
|
||||
if startIdx < deleteEnd {
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{StartIndex: startIdx, EndIndex: deleteEnd},
|
||||
},
|
||||
})
|
||||
}
|
||||
if plainText != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: startIdx},
|
||||
Text: plainText,
|
||||
},
|
||||
})
|
||||
if len(formats) > 0 {
|
||||
end := startIdx + int64(len(plainText))
|
||||
requests = append(requests, buildTextStyleRequests(formats, startIdx, end)...)
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
// sedOutputKV is an ordered key-value pair for sedOutputOK.
|
||||
type sedOutputKV struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
// sedOutputOK writes the standard sed output (status=ok, docId, plus extra key-value pairs).
|
||||
// Keys are output in the order provided.
|
||||
func sedOutputOK(ctx context.Context, u *ui.UI, id string, extra ...sedOutputKV) error {
|
||||
result := map[string]any{"status": "ok", "docId": id}
|
||||
for _, kv := range extra {
|
||||
result[kv.Key] = kv.Value
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, result)
|
||||
}
|
||||
u.Out().Printf("status\tok")
|
||||
u.Out().Printf("docId\t%s", id)
|
||||
for _, kv := range extra {
|
||||
u.Out().Printf("%s\t%v", kv.Key, kv.Value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTextStyleRequests creates UpdateTextStyle requests from format strings.
|
||||
// Handles: bold, italic, strikethrough, code, underline, link:URL, smallcaps,
|
||||
// font:NAME, size:N, color:#HEX, bg:#HEX
|
||||
// Returns empty slice if no text-level formats found.
|
||||
func buildTextStyleRequests(formats []string, start, end int64) []*docs.Request {
|
||||
textStyle := &docs.TextStyle{}
|
||||
var textFields []string
|
||||
|
||||
for _, f := range formats {
|
||||
switch f {
|
||||
case "bold":
|
||||
textStyle.Bold = true
|
||||
textFields = append(textFields, "bold")
|
||||
case "italic":
|
||||
textStyle.Italic = true
|
||||
textFields = append(textFields, "italic")
|
||||
case "strikethrough":
|
||||
textStyle.Strikethrough = true
|
||||
textFields = append(textFields, "strikethrough")
|
||||
case inlineTypeCode:
|
||||
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: "Courier New"}
|
||||
textStyle.BackgroundColor = greyColor(codeBackgroundGrey)
|
||||
textFields = append(textFields, "weightedFontFamily", "backgroundColor")
|
||||
case "underline":
|
||||
textStyle.Underline = true
|
||||
textFields = append(textFields, "underline")
|
||||
case "superscript":
|
||||
textStyle.BaselineOffset = "SUPERSCRIPT"
|
||||
textFields = append(textFields, "baselineOffset")
|
||||
case "subscript":
|
||||
textStyle.BaselineOffset = "SUBSCRIPT"
|
||||
textFields = append(textFields, "baselineOffset")
|
||||
case "smallcaps":
|
||||
textStyle.SmallCaps = true
|
||||
textFields = append(textFields, "smallCaps")
|
||||
default:
|
||||
switch {
|
||||
case strings.HasPrefix(f, "link:"):
|
||||
linkURL := f[5:]
|
||||
if strings.HasPrefix(linkURL, "#") {
|
||||
textStyle.Link = &docs.Link{BookmarkId: linkURL[1:]}
|
||||
} else {
|
||||
textStyle.Link = &docs.Link{Url: linkURL}
|
||||
}
|
||||
textFields = append(textFields, "link")
|
||||
case strings.HasPrefix(f, "font:"):
|
||||
fontName := f[5:]
|
||||
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: fontName}
|
||||
textFields = append(textFields, "weightedFontFamily")
|
||||
case strings.HasPrefix(f, "size:"):
|
||||
sizeStr := f[5:]
|
||||
if size, err := strconv.ParseFloat(sizeStr, 64); err == nil && size > 0 {
|
||||
textStyle.FontSize = &docs.Dimension{Magnitude: size, Unit: "PT"}
|
||||
textFields = append(textFields, "fontSize")
|
||||
}
|
||||
case strings.HasPrefix(f, "color:"):
|
||||
colorVal := f[6:]
|
||||
if r, g, b, ok := parseHexColor(colorVal); ok {
|
||||
textStyle.ForegroundColor = &docs.OptionalColor{
|
||||
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
|
||||
}
|
||||
textFields = append(textFields, "foregroundColor")
|
||||
}
|
||||
case strings.HasPrefix(f, "bg:"):
|
||||
bgVal := f[3:]
|
||||
if r, g, b, ok := parseHexColor(bgVal); ok {
|
||||
textStyle.BackgroundColor = &docs.OptionalColor{
|
||||
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
|
||||
}
|
||||
textFields = append(textFields, "backgroundColor")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(textFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*docs.Request{
|
||||
{
|
||||
UpdateTextStyle: &docs.UpdateTextStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
TextStyle: textStyle,
|
||||
Fields: strings.Join(textFields, ","),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildParagraphStyleRequests creates UpdateParagraphStyle and/or CreateParagraphBullets
|
||||
// requests from format strings. The end parameter should already include the +1 for
|
||||
// paragraph newline (caller decides).
|
||||
// Handles: heading1-6, bullet, checkbox, numbered.
|
||||
// Returns empty slice if no paragraph-level formats found.
|
||||
func buildParagraphStyleRequests(formats []string, start, end int64) []*docs.Request {
|
||||
var headingLevel int
|
||||
var bulletPreset string
|
||||
var isBlockquote bool
|
||||
|
||||
for _, f := range formats {
|
||||
if strings.HasPrefix(f, "heading") && len(f) == 8 {
|
||||
level := int(f[7] - '0')
|
||||
if level >= 1 && level <= 6 {
|
||||
headingLevel = level
|
||||
}
|
||||
}
|
||||
switch f {
|
||||
case "bullet":
|
||||
bulletPreset = bulletPresetDisc
|
||||
case "checkbox":
|
||||
bulletPreset = "BULLET_CHECKBOX"
|
||||
case "numbered":
|
||||
bulletPreset = "NUMBERED_DECIMAL_NESTED"
|
||||
case "blockquote":
|
||||
isBlockquote = true
|
||||
}
|
||||
}
|
||||
|
||||
var requests []*docs.Request
|
||||
|
||||
if headingLevel > 0 {
|
||||
namedStyle := fmt.Sprintf("HEADING_%d", headingLevel)
|
||||
requests = append(requests, &docs.Request{
|
||||
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
ParagraphStyle: &docs.ParagraphStyle{
|
||||
NamedStyleType: namedStyle,
|
||||
},
|
||||
Fields: "namedStyleType",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if bulletPreset != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
BulletPreset: bulletPreset,
|
||||
},
|
||||
})
|
||||
// Nesting is handled by prepending \t characters before the text content.
|
||||
// When CreateParagraphBullets is applied, Google Docs converts leading
|
||||
// tabs into nesting levels automatically. The tabs must already be in
|
||||
// the text BEFORE the bullet request. See buildNestedListText().
|
||||
}
|
||||
|
||||
if isBlockquote {
|
||||
requests = append(requests, &docs.Request{
|
||||
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
ParagraphStyle: &docs.ParagraphStyle{
|
||||
IndentStart: &docs.Dimension{Magnitude: blockquoteIndentPt, Unit: "PT"},
|
||||
BorderLeft: &docs.ParagraphBorder{
|
||||
Color: greyColor(borderGrey),
|
||||
Width: &docs.Dimension{Magnitude: blockquoteBorderWidthPt, Unit: "PT"},
|
||||
DashStyle: "SOLID",
|
||||
Padding: &docs.Dimension{Magnitude: blockquotePaddingPt, Unit: "PT"},
|
||||
},
|
||||
},
|
||||
Fields: "indentStart,borderLeft",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// buildHruleBorderRequest returns an UpdateParagraphStyle request that styles a paragraph
|
||||
// as a horizontal rule (bottom border only).
|
||||
func buildHruleBorderRequest(start, end int64) *docs.Request {
|
||||
return &docs.Request{
|
||||
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
ParagraphStyle: &docs.ParagraphStyle{
|
||||
BorderBottom: &docs.ParagraphBorder{
|
||||
Color: greyColor(borderGrey),
|
||||
Width: &docs.Dimension{Magnitude: 1, Unit: "PT"},
|
||||
DashStyle: "SOLID",
|
||||
Padding: &docs.Dimension{Magnitude: hrulePaddingPt, Unit: "PT"},
|
||||
},
|
||||
},
|
||||
Fields: "borderBottom",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// greyColor returns an OptionalColor with the given greyscale intensity (0.0=black, 1.0=white).
|
||||
func greyColor(intensity float64) *docs.OptionalColor {
|
||||
return &docs.OptionalColor{Color: &docs.Color{RgbColor: &docs.RgbColor{Red: intensity, Green: intensity, Blue: intensity}}}
|
||||
}
|
||||
|
||||
// containsFormat returns true if the format slice contains the given format string.
|
||||
func containsFormat(formats []string, f string) bool {
|
||||
for _, v := range formats {
|
||||
if v == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// inlineScriptRe matches inline superscript/subscript markers:
|
||||
// - {super=text} and {sub=text} (preferred)
|
||||
// - ^{text} and ~{text} (deprecated, still supported)
|
||||
// (Legacy buildInlineScriptRequests and buildAttrRequests removed — use brace syntax instead)
|
||||
|
||||
// parseHexColor converts #RRGGBB or #RGB to normalized 0.0-1.0 RGB floats.
|
||||
func parseHexColor(hex string) (r, g, b float64, ok bool) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
// Expand #RGB shorthand to #RRGGBB
|
||||
if len(hex) == 3 {
|
||||
hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
|
||||
}
|
||||
if len(hex) != 6 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
// Parse all 6 hex digits as a single uint to avoid 3 separate ParseUint calls.
|
||||
rgb, err := strconv.ParseUint(hex, 16, 24)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return float64((rgb>>16)&0xFF) / 255.0, float64((rgb>>8)&0xFF) / 255.0, float64(rgb&0xFF) / 255.0, true
|
||||
}
|
||||
|
||||
// Markdown escape placeholders — package-level to avoid per-call allocation.
|
||||
const (
|
||||
escAsterisk = "\x00ESC_ASTERISK\x00"
|
||||
escHash = "\x00ESC_HASH\x00"
|
||||
escTilde = "\x00ESC_TILDE\x00"
|
||||
escBacktick = "\x00ESC_BACKTICK\x00"
|
||||
escDash = "\x00ESC_DASH\x00"
|
||||
escPlus = "\x00ESC_PLUS\x00"
|
||||
escBackslash = "\x00ESC_BACKSLASH\x00"
|
||||
)
|
||||
|
||||
// Package-level replacers for markdown escape/unescape — allocated once.
|
||||
var (
|
||||
mdEscaper = strings.NewReplacer(
|
||||
"\\\\", escBackslash, "\\*", escAsterisk, "\\#", escHash,
|
||||
"\\~", escTilde, "\\`", escBacktick, "\\-", escDash,
|
||||
"\\+", escPlus, "\\n", "\n",
|
||||
)
|
||||
mdUnescaper = strings.NewReplacer(
|
||||
escAsterisk, "*", escHash, "#", escTilde, "~",
|
||||
escBacktick, "`", escDash, "-", escPlus, "+",
|
||||
escBackslash, "\\",
|
||||
)
|
||||
)
|
||||
|
||||
// escapeMarkdown replaces escaped markdown characters with placeholders.
|
||||
func escapeMarkdown(s string) string { return mdEscaper.Replace(s) }
|
||||
|
||||
// unescapeMarkdown restores escaped markdown characters from placeholders.
|
||||
func unescapeMarkdown(s string) string { return mdUnescaper.Replace(s) }
|
||||
|
||||
// nativeBlockMarkers are markdown format markers that prevent native API replacement.
|
||||
// Package-level to avoid per-call allocation.
|
||||
var nativeBlockMarkers = []string{
|
||||
"**", "*", "~~", "`",
|
||||
"# ", "## ", "### ", "#### ", "##### ", "###### ",
|
||||
"- ", "+ ",
|
||||
"> ",
|
||||
"[^",
|
||||
}
|
||||
|
||||
// canUseNativeReplace returns true if the replacement string contains no markdown
|
||||
// formatting or brace expressions that require manual (per-run) application,
|
||||
// allowing the faster native Google Docs FindReplace API to be used instead.
|
||||
func canUseNativeReplace(replacement string) bool {
|
||||
// Check for SEDMAT brace formatting ({b}, {c=red}, etc.)
|
||||
if hasBraceFormatting(replacement) {
|
||||
return false
|
||||
}
|
||||
// Check for image syntax (both  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
|
||||
}
|
||||
424
internal/cmd/docs_sed_images.go
Normal file
424
internal/cmd/docs_sed_images.go
Normal file
@ -0,0 +1,424 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func (c *DocsSedCmd) runImageReplace(ctx context.Context, u *ui.UI, account, docID string, ref *ImageRefPattern, replacement string, global bool) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
// Get document to find images
|
||||
var doc *docs.Document
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(docID).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
|
||||
// Find all images in document
|
||||
allImages := findDocImages(doc)
|
||||
if len(allImages) == 0 {
|
||||
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "no images found in document"})
|
||||
}
|
||||
|
||||
// Match images against pattern
|
||||
matched := matchImages(allImages, ref)
|
||||
if len(matched) == 0 {
|
||||
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "no images matched pattern"})
|
||||
}
|
||||
|
||||
// If not global, only process first match
|
||||
if !global && len(matched) > 1 {
|
||||
matched = matched[:1]
|
||||
}
|
||||
|
||||
// Parse replacement - could be new image, text, or empty (delete)
|
||||
var requests []*docs.Request
|
||||
isDelete := replacement == ""
|
||||
newImage := parseImageSyntax(replacement)
|
||||
if newImage == nil && strings.HasPrefix(replacement, "!(") && strings.HasSuffix(replacement, ")") {
|
||||
// Check for !(url) shorthand
|
||||
inner := replacement[2 : len(replacement)-1]
|
||||
if strings.HasPrefix(inner, "http://") || strings.HasPrefix(inner, "https://") {
|
||||
newImage = &ImageSpec{URL: inner}
|
||||
}
|
||||
}
|
||||
|
||||
// Build requests for each matched image
|
||||
for _, img := range matched {
|
||||
switch {
|
||||
case isDelete:
|
||||
// Delete the image
|
||||
if img.IsPositioned {
|
||||
requests = append(requests, &docs.Request{
|
||||
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
|
||||
ObjectId: img.ObjectID,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// For inline objects, delete the content range
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: img.Index,
|
||||
EndIndex: img.Index + 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
case newImage != nil:
|
||||
// Replace with new image
|
||||
if !img.IsPositioned {
|
||||
// Use ReplaceImage for inline images
|
||||
replaceReq := &docs.ReplaceImageRequest{
|
||||
ImageObjectId: img.ObjectID,
|
||||
Uri: newImage.URL,
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
ReplaceImage: replaceReq,
|
||||
})
|
||||
} else {
|
||||
// For positioned objects, delete and insert new
|
||||
requests = append(requests, &docs.Request{
|
||||
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
|
||||
ObjectId: img.ObjectID,
|
||||
},
|
||||
})
|
||||
// Note: Can't easily insert positioned object, so this is a limitation
|
||||
}
|
||||
default:
|
||||
// Replace with text - delete image, insert text
|
||||
if img.IsPositioned {
|
||||
requests = append(requests, &docs.Request{
|
||||
DeletePositionedObject: &docs.DeletePositionedObjectRequest{
|
||||
ObjectId: img.ObjectID,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: img.Index,
|
||||
EndIndex: img.Index + 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if replacement != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: img.Index},
|
||||
Text: replacement,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", 0})
|
||||
}
|
||||
|
||||
// Execute batch update
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("update document: %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, docID, sedOutputKV{"replaced", len(matched)})
|
||||
}
|
||||
|
||||
// ImageSpec holds the URL and optional dimensions for an inline image insertion.
|
||||
type ImageSpec struct {
|
||||
URL string
|
||||
Alt string
|
||||
Caption string // from title in 
|
||||
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], , , 
|
||||
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
|
||||
}
|
||||
|
||||
//  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: {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
|
||||
}
|
||||
114
internal/cmd/docs_sed_insert.go
Normal file
114
internal/cmd/docs_sed_insert.go
Normal file
@ -0,0 +1,114 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func (c *DocsSedCmd) doPositionalInsert(ctx context.Context, docsSvc *docs.Service, u *ui.UI, id string, idx int64, replacement string) error {
|
||||
// Check for image syntax first
|
||||
imgSpec := parseImageSyntax(replacement)
|
||||
|
||||
// Check for table creation (explicit |RxC| or pipe-table syntax)
|
||||
tableSpec := parseTableCreate(replacement)
|
||||
if tableSpec == nil {
|
||||
tableSpec = parseTableFromPipes(replacement)
|
||||
}
|
||||
|
||||
// Parse markdown formatting
|
||||
plainText, formats := parseMarkdownReplacement(replacement)
|
||||
|
||||
var requests []*docs.Request
|
||||
|
||||
switch {
|
||||
case tableSpec != nil:
|
||||
// Insert a table at the position
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTable: &docs.InsertTableRequest{
|
||||
Location: &docs.Location{Index: idx},
|
||||
Rows: int64(tableSpec.rows),
|
||||
Columns: int64(tableSpec.cols),
|
||||
},
|
||||
})
|
||||
case imgSpec != nil:
|
||||
// Insert an image
|
||||
imgReq := &docs.InsertInlineImageRequest{
|
||||
Uri: imgSpec.URL,
|
||||
Location: &docs.Location{Index: idx},
|
||||
ObjectSize: buildImageSizeSpec(imgSpec),
|
||||
}
|
||||
requests = append(requests, &docs.Request{InsertInlineImage: imgReq})
|
||||
default:
|
||||
// Insert plain text
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: idx},
|
||||
Text: plainText,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply text formatting if any
|
||||
if len(formats) > 0 {
|
||||
end := idx + int64(len(plainText))
|
||||
requests = append(requests, buildTextStyleRequests(formats, idx, end)...)
|
||||
}
|
||||
}
|
||||
|
||||
// After inserting text, reset paragraph styles and apply heading/bullet if requested
|
||||
if tableSpec == nil && imgSpec == nil && plainText != "" {
|
||||
end := idx + int64(len(plainText))
|
||||
|
||||
// Reset paragraph to NORMAL_TEXT (removes inherited heading styles)
|
||||
requests = append(requests, &docs.Request{
|
||||
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
|
||||
Range: &docs.Range{StartIndex: idx, EndIndex: end},
|
||||
ParagraphStyle: &docs.ParagraphStyle{
|
||||
NamedStyleType: "NORMAL_TEXT",
|
||||
},
|
||||
Fields: "namedStyleType",
|
||||
},
|
||||
})
|
||||
// Remove inherited bullets
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteParagraphBullets: &docs.DeleteParagraphBulletsRequest{
|
||||
Range: &docs.Range{StartIndex: idx, EndIndex: end},
|
||||
},
|
||||
})
|
||||
|
||||
// Apply heading/bullet formats from markdown
|
||||
requests = append(requests, buildParagraphStyleRequests(formats, idx, end)...)
|
||||
}
|
||||
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (positional insert): %w", err)
|
||||
}
|
||||
|
||||
// Fill pipe-table cells if content was provided
|
||||
if tableSpec != nil && len(tableSpec.cells) > 0 {
|
||||
if err := c.fillTableCells(ctx, docsSvc, id, idx, tableSpec); err != nil {
|
||||
return fmt.Errorf("fill table cells: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%d chars", len(plainText))
|
||||
if tableSpec != nil {
|
||||
label = fmt.Sprintf("%dx%d table", tableSpec.rows, tableSpec.cols)
|
||||
if len(tableSpec.cells) > 0 {
|
||||
label += " (filled)"
|
||||
}
|
||||
} else if imgSpec != nil {
|
||||
label = "image"
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"inserted", label})
|
||||
}
|
||||
416
internal/cmd/docs_sed_manual.go
Normal file
416
internal/cmd/docs_sed_manual.go
Normal file
@ -0,0 +1,416 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func (c *DocsSedCmd) runManual(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
count, bulletReqs, err := c.runManualInner(ctx, docsSvc, id, expr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manual replace: %w", err)
|
||||
}
|
||||
|
||||
// Apply deferred bullet requests via re-fetch to get current positions
|
||||
if len(bulletReqs) > 0 {
|
||||
if err := c.applyDeferredBullets(ctx, docsSvc, id); err != nil {
|
||||
return fmt.Errorf("apply bullets: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", count})
|
||||
}
|
||||
|
||||
// runManualInner is like runManual but reuses an existing docsSvc and returns count
|
||||
// plus any deferred bullet requests. Bullet requests are returned separately so that
|
||||
// the caller can merge consecutive same-preset bullets into a single request —
|
||||
// required for Google Docs to interpret leading \t as nesting levels.
|
||||
// sedMatch represents a single regex match found in the document with its replacement info.
|
||||
type sedMatch struct {
|
||||
start, end int64
|
||||
oldText string
|
||||
newText string
|
||||
formats []string
|
||||
image *ImageSpec
|
||||
braceExpr *braceExpr // SEDMAT v3.5 brace expression
|
||||
braceSpans []*braceSpan // Inline scoping spans
|
||||
}
|
||||
|
||||
// findDocMatches walks the document content, finds all regex matches, and returns them.
|
||||
// It also handles nth-match filtering and global vs single-match logic.
|
||||
func findDocMatches(doc *docs.Document, re *regexp.Regexp, expr sedExpr) []sedMatch {
|
||||
var matches []sedMatch
|
||||
|
||||
var walkContent func(content []*docs.StructuralElement)
|
||||
walkContent = func(content []*docs.StructuralElement) {
|
||||
for _, elem := range content {
|
||||
if elem.Paragraph != nil {
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun == nil || pe.TextRun.Content == "" {
|
||||
continue
|
||||
}
|
||||
text := pe.TextRun.Content
|
||||
baseIdx := pe.StartIndex
|
||||
limit := -1
|
||||
if !expr.global && expr.nthMatch <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
results := re.FindAllStringSubmatchIndex(text, limit)
|
||||
for _, loc := range results {
|
||||
oldText := text[loc[0]:loc[1]]
|
||||
expanded := re.ReplaceAllString(oldText, expr.replacement)
|
||||
matches = append(matches, classifyMatch(baseIdx, loc, oldText, expanded, expr))
|
||||
}
|
||||
}
|
||||
}
|
||||
if elem.Table != nil {
|
||||
for _, row := range elem.Table.TableRows {
|
||||
for _, cell := range row.TableCells {
|
||||
walkContent(cell.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if doc.Body != nil {
|
||||
walkContent(doc.Body.Content)
|
||||
}
|
||||
|
||||
// If nth-match is set, keep only the Nth occurrence across the whole document
|
||||
if expr.nthMatch > 0 {
|
||||
if len(matches) >= expr.nthMatch {
|
||||
return matches[expr.nthMatch-1 : expr.nthMatch]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// classifyMatch creates a sedMatch from a regex match, determining if it's an image,
|
||||
// brace expression, or plain text replacement.
|
||||
func classifyMatch(baseIdx int64, loc []int, oldText, expanded string, expr sedExpr) sedMatch {
|
||||
// Fast path: only attempt image parsing if replacement starts with ![
|
||||
var imgSpec *ImageSpec
|
||||
if strings.HasPrefix(expanded, "![") {
|
||||
imgSpec = parseImageSyntax(expanded)
|
||||
}
|
||||
switch {
|
||||
case imgSpec != nil:
|
||||
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, image: imgSpec}
|
||||
case expr.brace != nil && expr.brace.ImgRef != "":
|
||||
// Brace image: {img=url x=W y=H}
|
||||
spec := &ImageSpec{URL: expr.brace.ImgRef}
|
||||
if expr.brace.Width > 0 {
|
||||
spec.Width = expr.brace.Width
|
||||
}
|
||||
if expr.brace.Height > 0 {
|
||||
spec.Height = expr.brace.Height
|
||||
}
|
||||
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, image: spec}
|
||||
case expr.brace != nil:
|
||||
return sedMatch{
|
||||
start: baseIdx + int64(loc[0]),
|
||||
end: baseIdx + int64(loc[1]),
|
||||
oldText: oldText,
|
||||
newText: expanded,
|
||||
formats: braceExprToFormats(expr.brace),
|
||||
braceExpr: expr.brace,
|
||||
braceSpans: expr.braceSpans,
|
||||
}
|
||||
default:
|
||||
plainText, formats := parseMarkdownReplacement(expanded)
|
||||
return sedMatch{start: baseIdx + int64(loc[0]), end: baseIdx + int64(loc[1]), oldText: oldText, newText: plainText, formats: formats}
|
||||
}
|
||||
}
|
||||
|
||||
// processFootnotes handles footnote matches, each needing a two-phase create+populate approach.
|
||||
func processFootnotes(ctx context.Context, docsSvc *docs.Service, id string, footnoteMatches []sedMatch) error {
|
||||
for i := len(footnoteMatches) - 1; i >= 0; i-- {
|
||||
m := footnoteMatches[i]
|
||||
fnReqs := []*docs.Request{
|
||||
{DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
|
||||
}},
|
||||
{CreateFootnote: &docs.CreateFootnoteRequest{
|
||||
Location: &docs.Location{Index: m.start},
|
||||
}},
|
||||
}
|
||||
resp, err := batchUpdate(ctx, docsSvc, id, fnReqs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create footnote: %w", err)
|
||||
}
|
||||
// Find the footnote ID from the response and insert text into it
|
||||
if resp != nil {
|
||||
for _, reply := range resp.Replies {
|
||||
if reply.CreateFootnote != nil && reply.CreateFootnote.FootnoteId != "" {
|
||||
fnID := reply.CreateFootnote.FootnoteId
|
||||
fnTextReqs := []*docs.Request{
|
||||
{InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{
|
||||
Index: 1, // footnote body starts at index 1
|
||||
SegmentId: fnID,
|
||||
},
|
||||
Text: m.newText,
|
||||
}},
|
||||
}
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, fnTextReqs); err != nil {
|
||||
return fmt.Errorf("populate footnote: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatRange tracks a text range that needs formatting applied after insertion.
|
||||
type formatRange struct {
|
||||
start, end int64
|
||||
formats []string
|
||||
hasTab bool // replacement text starts with \t (nested list item)
|
||||
braceExpr *braceExpr // SEDMAT v3.5 brace expression
|
||||
braceSpans []*braceSpan // Inline scoping spans
|
||||
}
|
||||
|
||||
func (c *DocsSedCmd) runManualInner(ctx context.Context, docsSvc *docs.Service, id string, expr sedExpr) (int, []*docs.Request, error) {
|
||||
re, err := expr.compilePattern()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("compile pattern: %w", err)
|
||||
}
|
||||
|
||||
doc, err := getDoc(ctx, docsSvc, id)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
|
||||
matches := findDocMatches(doc, re, expr)
|
||||
if len(matches) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// Build requests in reverse order
|
||||
var requests []*docs.Request
|
||||
var formatRanges []formatRange
|
||||
|
||||
// Separate footnote and image matches — they need special handling
|
||||
var footnoteMatches []sedMatch
|
||||
var imageMatches []sedMatch
|
||||
var regularMatches []sedMatch
|
||||
for _, m := range matches {
|
||||
switch {
|
||||
case containsFormat(m.formats, "footnote"):
|
||||
footnoteMatches = append(footnoteMatches, m)
|
||||
case m.image != nil:
|
||||
imageMatches = append(imageMatches, m)
|
||||
default:
|
||||
regularMatches = append(regularMatches, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Process image matches individually — Google Docs API cannot handle
|
||||
// DeleteContentRange + InsertInlineImage in the same batch request
|
||||
// (it fails to fetch the image URL when combined with other operations).
|
||||
for i := len(imageMatches) - 1; i >= 0; i-- {
|
||||
m := imageMatches[i]
|
||||
// First: delete the matched text
|
||||
deleteReqs := []*docs.Request{{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
|
||||
},
|
||||
}}
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, deleteReqs); err != nil {
|
||||
return 0, nil, fmt.Errorf("delete before image insert: %w", err)
|
||||
}
|
||||
// Then: insert image in a separate API call
|
||||
imgReq := &docs.InsertInlineImageRequest{
|
||||
Uri: m.image.URL,
|
||||
Location: &docs.Location{Index: m.start},
|
||||
ObjectSize: buildImageSizeSpec(m.image),
|
||||
}
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, []*docs.Request{{InsertInlineImage: imgReq}}); err != nil {
|
||||
return 0, nil, fmt.Errorf("image insert (url=%s idx=%d): %w", m.image.URL, m.start, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(regularMatches) - 1; i >= 0; i-- {
|
||||
m := regularMatches[i]
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{StartIndex: m.start, EndIndex: m.end},
|
||||
},
|
||||
})
|
||||
|
||||
switch {
|
||||
case containsFormat(m.formats, "hrule"):
|
||||
// Horizontal rule: insert a newline, then style it with a bottom border
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: m.start},
|
||||
Text: "\n",
|
||||
},
|
||||
})
|
||||
requests = append(requests, buildHruleBorderRequest(m.start, m.start+1))
|
||||
default:
|
||||
if m.newText != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: m.start},
|
||||
Text: m.newText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if m.newText != "" && (len(m.formats) > 0 || m.braceExpr != nil) {
|
||||
fmts := m.formats
|
||||
if containsFormat(fmts, "codeblock") {
|
||||
fmts = append(fmts, "code")
|
||||
}
|
||||
formatRanges = append(formatRanges, formatRange{
|
||||
start: m.start,
|
||||
end: m.start + int64(len(m.newText)),
|
||||
formats: fmts,
|
||||
hasTab: strings.HasPrefix(m.newText, "\t"),
|
||||
braceExpr: m.braceExpr,
|
||||
braceSpans: m.braceSpans,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add text-level formatting (bold, italic, code, super/sub, etc.)
|
||||
for _, fr := range formatRanges {
|
||||
if fr.braceExpr != nil {
|
||||
// SEDMAT v3.5 brace syntax path
|
||||
requests = append(requests, buildBraceTextStyleRequests(fr.braceExpr, fr.start, fr.end)...)
|
||||
// Handle inline scoping spans
|
||||
requests = append(requests, buildBraceInlineRequests(fr.braceSpans, fr.start)...)
|
||||
} else {
|
||||
requests = append(requests, buildTextStyleRequests(fr.formats, fr.start, fr.end)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Split paragraph-level requests into bullet requests (deferred) and
|
||||
// non-bullet requests (headings, blockquotes — applied immediately).
|
||||
// Bullets are deferred so the caller can merge consecutive same-preset
|
||||
// bullets into a single CreateParagraphBullets call, which is required
|
||||
// for Google Docs to interpret leading \t as nesting levels.
|
||||
var paraRequests []*docs.Request
|
||||
var deferredBullets []*docs.Request
|
||||
for _, fr := range formatRanges {
|
||||
paraEnd := fr.end + 1
|
||||
// Use brace paragraph formatting if available
|
||||
if fr.braceExpr != nil && hasBraceParagraphFormat(fr.braceExpr) {
|
||||
paraRequests = append(paraRequests, buildBraceParagraphStyleRequests(fr.braceExpr, fr.start, paraEnd)...)
|
||||
} else {
|
||||
for _, req := range buildParagraphStyleRequests(fr.formats, fr.start, paraEnd) {
|
||||
if req.CreateParagraphBullets != nil && fr.hasTab {
|
||||
// Nested bullets (have \t) are deferred so the caller can merge
|
||||
// them with adjacent L0 bullets for proper nesting.
|
||||
deferredBullets = append(deferredBullets, req)
|
||||
} else {
|
||||
paraRequests = append(paraRequests, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: inserts, deletes, text formatting
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return 0, nil, fmt.Errorf("update document: %w", err)
|
||||
}
|
||||
|
||||
// Phase 2: non-bullet paragraph styles (headings, blockquotes)
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, paraRequests); err != nil {
|
||||
return 0, nil, fmt.Errorf("apply paragraph styles: %w", err)
|
||||
}
|
||||
|
||||
// Handle footnotes — each needs create + populate, processed individually in reverse
|
||||
if err = processFootnotes(ctx, docsSvc, id, footnoteMatches); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// Phase 3: insert page/section/column break if {+=X} or {break=X} is set.
|
||||
if err = applyBreakPhase(ctx, docsSvc, id, expr, formatRanges); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// Phase 4: Apply structural features (columns, checkboxes, bookmarks, smart chips).
|
||||
// Requires re-fetching the document since text indices shifted in Phase 1.
|
||||
if expr.brace != nil && hasBraceStructuralFeatures(expr.brace) {
|
||||
freshDoc, err := getDoc(ctx, docsSvc, id)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("get doc for structural: %w", err)
|
||||
}
|
||||
|
||||
// Collect all structural requests
|
||||
var allStructuralReqs []*docs.Request
|
||||
|
||||
for _, fr := range formatRanges {
|
||||
if fr.braceExpr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get section boundaries for columns
|
||||
sectionStart, sectionEnd := buildSectionRangeForMatch(freshDoc, fr.start, fr.end)
|
||||
|
||||
// Build structural requests
|
||||
colReqs, bulletReqs, anchorReqs, chipReqs := buildStructuralRequests(
|
||||
fr.braceExpr, fr.start, fr.end, sectionStart, sectionEnd,
|
||||
)
|
||||
|
||||
allStructuralReqs = append(allStructuralReqs, colReqs...)
|
||||
allStructuralReqs = append(allStructuralReqs, anchorReqs...)
|
||||
allStructuralReqs = append(allStructuralReqs, chipReqs...)
|
||||
|
||||
// Add checkbox bullets to deferred bullets
|
||||
deferredBullets = append(deferredBullets, bulletReqs...)
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, allStructuralReqs); err != nil {
|
||||
return 0, nil, fmt.Errorf("apply structural features: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return len(matches), deferredBullets, nil
|
||||
}
|
||||
|
||||
// applyBreakPhase inserts page/section/column breaks after all text modifications.
|
||||
func applyBreakPhase(ctx context.Context, docsSvc *docs.Service, id string, expr sedExpr, formatRanges []formatRange) error {
|
||||
if expr.brace == nil || !expr.brace.HasBreak || len(formatRanges) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
freshDoc, err := getDoc(ctx, docsSvc, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get doc for break: %w", err)
|
||||
}
|
||||
|
||||
lastEnd := formatRanges[len(formatRanges)-1].end
|
||||
breakIdx := lastEnd + 1
|
||||
if freshDoc.Body != nil && len(freshDoc.Body.Content) > 0 {
|
||||
bodyEnd := freshDoc.Body.Content[len(freshDoc.Body.Content)-1].EndIndex
|
||||
if breakIdx >= bodyEnd {
|
||||
breakIdx = bodyEnd - 1
|
||||
}
|
||||
}
|
||||
|
||||
breakReqs := buildBraceBreakRequests(expr.brace, breakIdx)
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, breakReqs); err != nil {
|
||||
return fmt.Errorf("insert break: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
242
internal/cmd/docs_sed_nesting.go
Normal file
242
internal/cmd/docs_sed_nesting.go
Normal file
@ -0,0 +1,242 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
)
|
||||
|
||||
// applyDeferredBullets re-fetches the document and finds paragraphs that have
|
||||
// leading \t characters (indicating pending bullet creation with nesting).
|
||||
// It groups consecutive paragraphs by bullet preset and applies a single
|
||||
// CreateParagraphBullets request per group, which allows Google Docs to
|
||||
// interpret the tabs as nesting levels.
|
||||
//
|
||||
// Per the Google Docs API: "The nesting level of each paragraph is determined
|
||||
// by counting leading tabs in front of each paragraph."
|
||||
func (c *DocsSedCmd) applyDeferredBullets(ctx context.Context, docsSvc *docs.Service, id string) error {
|
||||
var doc *docs.Document
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if doc.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find paragraphs that need bullets. These are paragraphs with leading \t
|
||||
// that don't already have a bullet (bullets were deferred).
|
||||
// Also include non-tab paragraphs that are adjacent to tab paragraphs
|
||||
// and match the same list type (they're the L0 parents).
|
||||
type pendingBullet struct {
|
||||
startIndex int64
|
||||
endIndex int64
|
||||
preset string
|
||||
hasTab bool
|
||||
}
|
||||
var pending []pendingBullet
|
||||
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.Paragraph == nil {
|
||||
continue
|
||||
}
|
||||
// Skip paragraphs that already have bullets
|
||||
if elem.Paragraph.Bullet != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check text content for leading tab or bullet-like content
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun == nil {
|
||||
continue
|
||||
}
|
||||
content := pe.TextRun.Content
|
||||
hasTab := strings.HasPrefix(content, "\t")
|
||||
if hasTab {
|
||||
// This paragraph has a deferred nested bullet
|
||||
pending = append(pending, pendingBullet{
|
||||
startIndex: elem.StartIndex,
|
||||
endIndex: elem.EndIndex,
|
||||
preset: bulletPresetDisc, // default, will be refined
|
||||
hasTab: true,
|
||||
})
|
||||
}
|
||||
break // only check first text run
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now we need to also find the L0 parent paragraphs. These are non-tab
|
||||
// paragraphs immediately before a tab paragraph that were also bulleted
|
||||
// (they already have bullets from their own runManualInner call).
|
||||
// We need to include them in the same CreateParagraphBullets range.
|
||||
//
|
||||
// Strategy: expand each group to include adjacent bulleted paragraphs.
|
||||
// Re-scan all paragraphs and build a map of paragraph ranges.
|
||||
type paraInfo struct {
|
||||
startIndex int64
|
||||
endIndex int64
|
||||
hasBullet bool
|
||||
hasTab bool
|
||||
preset string
|
||||
}
|
||||
var allParas []paraInfo
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.Paragraph == nil {
|
||||
continue
|
||||
}
|
||||
pi := paraInfo{
|
||||
startIndex: elem.StartIndex,
|
||||
endIndex: elem.EndIndex,
|
||||
hasBullet: elem.Paragraph.Bullet != nil,
|
||||
}
|
||||
if pi.hasBullet {
|
||||
pi.preset = inferBulletPreset(doc, elem.Paragraph.Bullet.ListId)
|
||||
}
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun != nil {
|
||||
pi.hasTab = strings.HasPrefix(pe.TextRun.Content, "\t")
|
||||
break
|
||||
}
|
||||
}
|
||||
allParas = append(allParas, pi)
|
||||
}
|
||||
|
||||
// Find groups: consecutive paragraphs where at least one has a tab
|
||||
// and all are either bulleted or have tabs (need bullets).
|
||||
type group struct {
|
||||
start, end int64
|
||||
preset string
|
||||
}
|
||||
var groups []group
|
||||
|
||||
for i := 0; i < len(allParas); i++ {
|
||||
p := allParas[i]
|
||||
if !p.hasTab && !p.hasBullet {
|
||||
continue
|
||||
}
|
||||
|
||||
// Start a potential group
|
||||
groupStart := p.startIndex
|
||||
groupEnd := p.endIndex - 1
|
||||
preset := p.preset
|
||||
if preset == "" {
|
||||
preset = bulletPresetDisc
|
||||
}
|
||||
hasAnyTab := p.hasTab
|
||||
|
||||
// Extend forward through adjacent bulleted/tab paragraphs of the same type.
|
||||
// Break when the preset changes (e.g., bullet → numbered).
|
||||
for i+1 < len(allParas) {
|
||||
next := allParas[i+1]
|
||||
if !next.hasTab && !next.hasBullet {
|
||||
break
|
||||
}
|
||||
// Break if the next paragraph has a different preset (different list type)
|
||||
if next.preset != "" && preset != "" && next.preset != preset {
|
||||
break
|
||||
}
|
||||
i++
|
||||
groupEnd = next.endIndex - 1
|
||||
if next.hasTab {
|
||||
hasAnyTab = true
|
||||
}
|
||||
if next.preset != "" {
|
||||
preset = next.preset
|
||||
}
|
||||
}
|
||||
|
||||
// Only create a group if it contains at least one tab paragraph
|
||||
if hasAnyTab {
|
||||
groups = append(groups, group{start: groupStart, end: groupEnd, preset: preset})
|
||||
}
|
||||
}
|
||||
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get document body end index for clamping
|
||||
var bodyEnd int64
|
||||
if len(doc.Body.Content) > 0 {
|
||||
bodyEnd = doc.Body.Content[len(doc.Body.Content)-1].EndIndex
|
||||
}
|
||||
|
||||
// For each group: delete existing bullets (if any), then re-create merged
|
||||
var requests []*docs.Request
|
||||
for i := range groups {
|
||||
g := &groups[i]
|
||||
// Clamp end to the last content index. The paragraph endIndex is
|
||||
// exclusive, and the body's final newline sits at the segment boundary.
|
||||
// Use endIndex-1 to stay within valid range.
|
||||
if g.end > bodyEnd-1 {
|
||||
g.end = bodyEnd - 1
|
||||
}
|
||||
if g.start >= g.end {
|
||||
continue
|
||||
}
|
||||
// Delete existing bullets first (some L0 items already have them)
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteParagraphBullets: &docs.DeleteParagraphBulletsRequest{
|
||||
Range: &docs.Range{StartIndex: g.start, EndIndex: g.end},
|
||||
},
|
||||
})
|
||||
// Re-create with merged range — tabs become nesting levels
|
||||
requests = append(requests, &docs.Request{
|
||||
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
|
||||
Range: &docs.Range{StartIndex: g.start, EndIndex: g.end},
|
||||
BulletPreset: g.preset,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Process first group only — then recursively handle remaining groups
|
||||
// by re-fetching the doc (indices shift when tabs are consumed by bullets).
|
||||
if len(requests) >= 2 {
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests[:2],
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If more groups remain, re-run to pick them up with fresh indices
|
||||
if len(requests) > 2 {
|
||||
return c.applyDeferredBullets(ctx, docsSvc, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferBulletPreset determines the bullet preset from the list properties.
|
||||
func inferBulletPreset(doc *docs.Document, listID string) string {
|
||||
if doc.Lists == nil {
|
||||
return bulletPresetDisc
|
||||
}
|
||||
list, ok := doc.Lists[listID]
|
||||
if !ok || list.ListProperties == nil {
|
||||
return bulletPresetDisc
|
||||
}
|
||||
|
||||
levels := list.ListProperties.NestingLevels
|
||||
if len(levels) > 0 && levels[0] != nil {
|
||||
switch levels[0].GlyphType {
|
||||
case "DECIMAL", "ZERO_DECIMAL", "UPPER_ALPHA", "ALPHA",
|
||||
"UPPER_ROMAN", "ROMAN":
|
||||
return "NUMBERED_DECIMAL_NESTED"
|
||||
}
|
||||
}
|
||||
|
||||
return bulletPresetDisc
|
||||
}
|
||||
543
internal/cmd/docs_sed_parse.go
Normal file
543
internal/cmd/docs_sed_parse.go
Normal file
@ -0,0 +1,543 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const backrefPlaceholder = "\x00BACKREF_"
|
||||
|
||||
// sedBackrefRe matches sed-style backreferences (\1 through \9).
|
||||
var sedBackrefRe = regexp.MustCompile(`\\(\d)`)
|
||||
|
||||
var backrefReplacer = strings.NewReplacer(func() []string {
|
||||
var pairs []string
|
||||
for d := 1; d <= 9; d++ {
|
||||
pairs = append(pairs, backrefPlaceholder+strconv.Itoa(d)+"\x00", fmt.Sprintf("${%d}", d))
|
||||
}
|
||||
return pairs
|
||||
}()...)
|
||||
|
||||
// parseSedExpr parses a sed expression (s/pattern/replacement/flags) into its components.
|
||||
// It handles escaped delimiters, backref conversion ($N), and & whole-match expansion.
|
||||
func parseSedExpr(expr string) (pattern, replacement string, global bool, err error) {
|
||||
if len(expr) < 4 || expr[0] != 's' {
|
||||
return "", "", false, fmt.Errorf("invalid sed expression (expected s/pattern/replacement/[flags])")
|
||||
}
|
||||
|
||||
delim := expr[1]
|
||||
|
||||
// Split respecting escaped delimiters (\/ when delim is /)
|
||||
parts := splitByDelim(expr[2:], delim)
|
||||
|
||||
if len(parts) < 2 {
|
||||
return "", "", false, fmt.Errorf("invalid sed expression (missing replacement)")
|
||||
}
|
||||
|
||||
pattern = parts[0]
|
||||
replacement = parts[1]
|
||||
|
||||
// Check flags
|
||||
flags := flagsFromParts(parts, 2)
|
||||
global = strings.Contains(flags, "g")
|
||||
pattern = applyRegexFlags(pattern, flags)
|
||||
|
||||
// Process replacement escapes and backreferences.
|
||||
// In sed replacements: \1-\9 are backrefs, \$ is literal $, \. is literal ., \\ is literal \, etc.
|
||||
// In Go regex replacements: $1/${1} are backrefs, $$ is literal $.
|
||||
|
||||
// Step 1: Convert sed backrefs \1-\9 to placeholders
|
||||
replacement = sedBackrefRe.ReplaceAllString(replacement, backrefPlaceholder+"${1}\x00")
|
||||
|
||||
// Step 2: Process replacement string — unescape sed escapes and handle $ for Go regex.
|
||||
// In one pass: \$ → literal dollar (escaped as $$ for Go), \. → ., \\ → \,
|
||||
// $N → backref placeholder, other $ → $$ (literal for Go).
|
||||
// Preserve \n, \t for later processing.
|
||||
var processed strings.Builder
|
||||
for i := 0; i < len(replacement); i++ {
|
||||
switch {
|
||||
case replacement[i] == '\\' && i+1 < len(replacement):
|
||||
next := replacement[i+1]
|
||||
switch next {
|
||||
case '$':
|
||||
// \$ in sed replacement = literal $ → escape as $$ for Go regex
|
||||
processed.WriteString("$$")
|
||||
i++
|
||||
case '.', '^', '[', ']', '(', ')', '{', '}', '+', '?', '|':
|
||||
// Unescape regex metacharacters to literals
|
||||
processed.WriteByte(next)
|
||||
i++
|
||||
case '&':
|
||||
// \& = literal & (not a whole-match backref) — use placeholder
|
||||
processed.WriteString("\x00LITAMP\x00")
|
||||
i++
|
||||
case '\\':
|
||||
processed.WriteByte('\\')
|
||||
i++
|
||||
default:
|
||||
// Preserve other escapes (\n, \t, etc.)
|
||||
processed.WriteByte('\\')
|
||||
}
|
||||
case replacement[i] == '$':
|
||||
switch {
|
||||
case i+1 < len(replacement) && replacement[i+1] == '$':
|
||||
// $$ in sed replacement = literal $ → escape as $$ for Go regex
|
||||
processed.WriteString("$$")
|
||||
i++ // skip second $
|
||||
case i+1 < len(replacement) && replacement[i+1] >= '1' && replacement[i+1] <= '9':
|
||||
// $N backref — convert to placeholder
|
||||
processed.WriteString(backrefPlaceholder)
|
||||
processed.WriteByte(replacement[i+1])
|
||||
processed.WriteByte('\x00')
|
||||
i++
|
||||
case i+1 < len(replacement) && replacement[i+1] == '{':
|
||||
// ${N} backref — pass through
|
||||
processed.WriteByte('$')
|
||||
default:
|
||||
// Literal $ not followed by digit or $ — escape for Go regex
|
||||
processed.WriteString("$$")
|
||||
}
|
||||
default:
|
||||
processed.WriteByte(replacement[i])
|
||||
}
|
||||
}
|
||||
replacement = processed.String()
|
||||
|
||||
// Step 3: Restore backrefs from placeholders → Go-style ${N}
|
||||
replacement = backrefReplacer.Replace(replacement)
|
||||
|
||||
// Step 4: Handle sed & (whole match) → Go's ${0}
|
||||
// Unescaped & means "whole match" in sed. \& was converted to \x00LITAMP\x00 in Step 2.
|
||||
replacement = strings.ReplaceAll(replacement, "&", "${0}")
|
||||
replacement = strings.ReplaceAll(replacement, "\x00LITAMP\x00", "&")
|
||||
|
||||
return pattern, replacement, global, nil
|
||||
}
|
||||
|
||||
// parseSedExprWithCell parses a sed expression and extracts any table cell reference from the pattern.
|
||||
// parseTableRef checks if a pattern is a bare table reference like |1|, |2|, |-1|, |*|
|
||||
// Returns (tableIndex, ok). tableIndex is 1-indexed, negative from end, 0 means |*| (all).
|
||||
func parseTableRef(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 3 || s[0] != '|' || s[len(s)-1] != '|' {
|
||||
return 0, false
|
||||
}
|
||||
inner := s[1 : len(s)-1]
|
||||
// Don't match table creation specs (contain 'x')
|
||||
if strings.ContainsAny(inner, "xX") {
|
||||
return 0, false
|
||||
}
|
||||
if inner == "*" {
|
||||
return math.MinInt32, true // all tables
|
||||
}
|
||||
n, err := strconv.Atoi(inner)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Cell ref patterns: s/|1|[2,3]/replacement/ or s/|1|[A1]:subpattern/replacement/
|
||||
func parseSedExprWithCell(expr string) (pattern, replacement string, global bool, cellRef *tableCellRef, err error) {
|
||||
pattern, replacement, global, err = parseSedExpr(expr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pattern is a table cell reference
|
||||
ref := parseTableCellRef(pattern)
|
||||
if ref != nil {
|
||||
cellRef = ref
|
||||
if ref.subPattern != "" {
|
||||
pattern = ref.subPattern
|
||||
} else {
|
||||
pattern = "" // whole cell replacement
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// parseMarkdownReplacement extracts text and formatting from markdown-style replacement.
|
||||
// Supported standard CommonMark formats: **bold**, *italic*, ***bold+italic***,
|
||||
// ~~strikethrough~~, `code`, [text](url), # through ###### headings,
|
||||
// - bullets, 1. numbered, > blockquotes, --- hrules, ```codeblocks```, [^footnotes].
|
||||
// Escape sequences: \*, \#, \~, \`, \-, \+, \\, \n.
|
||||
// Returns: plain text, format strings (e.g., "bold", "heading2", "link:url").
|
||||
func parseMarkdownReplacement(repl string) (text string, formats []string) {
|
||||
text = repl
|
||||
|
||||
// Process escape sequences and restore on return
|
||||
text = escapeMarkdown(text)
|
||||
defer func() { text = unescapeMarkdown(text) }()
|
||||
|
||||
// Horizontal rule (---, ***, ___) — must be exactly 3 of the same char
|
||||
// (not 4+ which could be bold/italic markers)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||
return "\n", []string{"hrule"}
|
||||
}
|
||||
|
||||
// Fenced code block (```...```)
|
||||
if strings.HasPrefix(text, "```") && strings.HasSuffix(text, "```") && len(text) > 6 {
|
||||
inner := text[3 : len(text)-3]
|
||||
// Strip optional language hint on first line (e.g., ```go)
|
||||
if idx := strings.Index(inner, "\n"); idx >= 0 {
|
||||
inner = inner[idx+1:]
|
||||
}
|
||||
return inner, append(formats, "codeblock")
|
||||
}
|
||||
|
||||
// Blockquote (> text)
|
||||
if strings.HasPrefix(text, "> ") {
|
||||
return text[2:], append(formats, "blockquote")
|
||||
}
|
||||
|
||||
// Footnote [^text] — creates a footnote with the given text
|
||||
if strings.HasPrefix(text, "[^") && strings.HasSuffix(text, "]") && len(text) > 3 {
|
||||
footnoteText := text[2 : len(text)-1]
|
||||
return footnoteText, []string{"footnote"}
|
||||
}
|
||||
|
||||
// Check for list prefixes with optional indentation for nesting
|
||||
// Detect indent level: 2 spaces = 1 level, 4 spaces = 2 levels, etc.
|
||||
indentLevel := 0
|
||||
listText := text
|
||||
for strings.HasPrefix(listText, " ") {
|
||||
indentLevel++
|
||||
listText = listText[2:]
|
||||
}
|
||||
|
||||
listFormat := ""
|
||||
switch {
|
||||
case strings.HasPrefix(listText, "- "):
|
||||
text = listText[2:]
|
||||
listFormat = "bullet"
|
||||
case strings.HasPrefix(listText, "* ") && !strings.HasSuffix(listText, "*"):
|
||||
text = listText[2:]
|
||||
listFormat = "bullet"
|
||||
case len(listText) > 2 && listText[0] >= '0' && listText[0] <= '9' && listText[1] == '.' && listText[2] == ' ':
|
||||
text = listText[3:]
|
||||
listFormat = "numbered"
|
||||
}
|
||||
|
||||
if listFormat != "" {
|
||||
formats = append(formats, listFormat)
|
||||
if indentLevel > 0 {
|
||||
// Prepend tab characters for nesting. When CreateParagraphBullets
|
||||
// is applied, Google Docs converts leading \t into nesting levels.
|
||||
text = strings.Repeat("\t", indentLevel) + text
|
||||
}
|
||||
}
|
||||
|
||||
// Bold+italic (***text***)
|
||||
if strings.HasPrefix(text, "***") && strings.HasSuffix(text, "***") && len(text) > 6 {
|
||||
return text[3 : len(text)-3], append(formats, "bold", "italic")
|
||||
}
|
||||
// Bold (**text**)
|
||||
if strings.HasPrefix(text, "**") && strings.HasSuffix(text, "**") && len(text) > 4 {
|
||||
return text[2 : len(text)-2], append(formats, "bold")
|
||||
}
|
||||
// Italic (*text*) - only if not already parsed as bullet
|
||||
if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") && len(text) > 2 {
|
||||
return text[1 : len(text)-1], append(formats, "italic")
|
||||
}
|
||||
// Strikethrough (~~text~~)
|
||||
if strings.HasPrefix(text, "~~") && strings.HasSuffix(text, "~~") && len(text) > 4 {
|
||||
return text[2 : len(text)-2], append(formats, "strikethrough")
|
||||
}
|
||||
// Code (`text`)
|
||||
if strings.HasPrefix(text, "`") && strings.HasSuffix(text, "`") && len(text) > 2 {
|
||||
return text[1 : len(text)-1], append(formats, "code")
|
||||
}
|
||||
// Link [text](url) — can appear anywhere in the replacement
|
||||
if idx := strings.Index(text, "]("); idx > 0 && strings.HasPrefix(text, "[") {
|
||||
closeParen := strings.LastIndex(text, ")")
|
||||
if closeParen > idx+2 {
|
||||
linkText := text[1:idx]
|
||||
linkURL := text[idx+2 : closeParen]
|
||||
linkURL = strings.ReplaceAll(linkURL, "\\/", "/")
|
||||
return linkText, append(formats, "link:"+linkURL)
|
||||
}
|
||||
}
|
||||
// Headings (#text, ##text, etc)
|
||||
if strings.HasPrefix(text, "#") {
|
||||
level := 0
|
||||
for i := 0; i < len(text) && i < 6; i++ {
|
||||
if text[i] == '#' {
|
||||
level++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if level > 0 && level <= 6 {
|
||||
stripped := strings.TrimPrefix(text[level:], " ")
|
||||
return stripped, append(formats, fmt.Sprintf("heading%d", level))
|
||||
}
|
||||
}
|
||||
|
||||
return text, formats
|
||||
}
|
||||
|
||||
// parseFullExpr parses a raw expression string into a sedExpr, handling all command types
|
||||
// (s//, d//, a//, i//, y//) and flags (g, i, m, N for nth occurrence).
|
||||
func parseFullExpr(raw string) (sedExpr, error) {
|
||||
if len(raw) == 0 {
|
||||
return sedExpr{}, fmt.Errorf("empty expression")
|
||||
}
|
||||
|
||||
// Check for non-substitution commands: d, a, i, y
|
||||
// Only treat as command if followed by a non-alphanumeric delimiter (like /)
|
||||
if len(raw) >= 2 && !isAlphanumeric(raw[1]) {
|
||||
switch raw[0] {
|
||||
case 'd':
|
||||
return parseDCommand(raw)
|
||||
case 'a':
|
||||
return parseAICommand(raw, 'a')
|
||||
case 'i':
|
||||
return parseAICommand(raw, 'i')
|
||||
case 'y':
|
||||
return parseYCommand(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Standard s// command
|
||||
pattern, replacement, global, cellRef, err := parseSedExprWithCell(raw)
|
||||
if err != nil {
|
||||
return sedExpr{}, err
|
||||
}
|
||||
|
||||
expr := sedExpr{pattern: pattern, replacement: replacement, global: global, cellRef: cellRef}
|
||||
|
||||
// Check for brace pattern-side addressing: {T=...} or {img=...}
|
||||
// This is SEDMAT v3.5 syntax for table/image addressing in the pattern position
|
||||
if cellRef == nil && strings.HasPrefix(pattern, "{") {
|
||||
remaining, tableRef, imgRef, braceErr := detectBracePattern(pattern)
|
||||
if braceErr != nil {
|
||||
return sedExpr{}, fmt.Errorf("brace pattern: %w", braceErr)
|
||||
}
|
||||
if tableRef != nil {
|
||||
// Bridge to existing table machinery
|
||||
braceTableToSedExpr(tableRef, &expr)
|
||||
expr.pattern = remaining
|
||||
// If this is a table creation spec, handle via replacement
|
||||
if tableRef.IsCreate {
|
||||
spec := braceTableToTableCreateSpec(tableRef)
|
||||
if spec != nil {
|
||||
// Convert to pipe-style for existing machinery
|
||||
if spec.header {
|
||||
expr.replacement = fmt.Sprintf("|%dx%d:header|", spec.rows, spec.cols)
|
||||
} else {
|
||||
expr.replacement = fmt.Sprintf("|%dx%d|", spec.rows, spec.cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgRef != nil {
|
||||
// Bridge to existing image machinery via pattern
|
||||
imgPattern := braceImgToImageRefPattern(imgRef)
|
||||
if imgPattern != nil {
|
||||
switch {
|
||||
case imgPattern.AllImages:
|
||||
expr.pattern = "!(*)"
|
||||
case imgPattern.ByPosition:
|
||||
expr.pattern = fmt.Sprintf("!(%d)", imgPattern.Position)
|
||||
case imgPattern.ByAlt && imgPattern.AltRegex != nil:
|
||||
expr.pattern = fmt.Sprintf("![%s]", imgRef.Pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pattern is a bare table reference (|1|, |-1|, |*|) — legacy pipe syntax
|
||||
if expr.cellRef == nil && expr.tableRef == 0 {
|
||||
if tIdx, ok := parseTableRef(expr.pattern); ok {
|
||||
expr.tableRef = tIdx
|
||||
expr.pattern = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Extract nth-match flag from raw expression flags
|
||||
expr.nthMatch = parseNthFlag(raw)
|
||||
|
||||
// Check for brace formatting in replacement (SEDMAT v3.5 syntax)
|
||||
if hasBraceFormatting(replacement) {
|
||||
cleanedText, spans := findBraceExprs(replacement)
|
||||
if len(spans) > 0 {
|
||||
expr.replacement = cleanedText
|
||||
expr.braceSpans = spans
|
||||
// If there's exactly one global span, use it as the main brace expr
|
||||
if len(spans) == 1 && spans[0].IsGlobal {
|
||||
expr.brace = spans[0].Expr
|
||||
} else {
|
||||
// Multiple spans or non-global: merge into one brace for global formatting
|
||||
expr.brace = mergeBraceSpans(spans)
|
||||
}
|
||||
|
||||
// Handle {T=NxM} table creation in replacement position:
|
||||
// convert to pipe-style spec for existing table machinery.
|
||||
if expr.brace != nil && expr.brace.TableRef != "" {
|
||||
bt, btErr := parseBraceTableRef(expr.brace.TableRef)
|
||||
if btErr == nil && bt.IsCreate {
|
||||
spec := braceTableToTableCreateSpec(bt)
|
||||
if spec != nil {
|
||||
if spec.header {
|
||||
expr.replacement = fmt.Sprintf("|%dx%d:header|", spec.rows, spec.cols)
|
||||
} else {
|
||||
expr.replacement = fmt.Sprintf("|%dx%d|", spec.rows, spec.cols)
|
||||
}
|
||||
expr.brace = nil
|
||||
expr.braceSpans = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// parseNthFlag extracts the Nth occurrence flag (e.g., "2" in s/foo/bar/2) from a raw expression.
|
||||
func parseNthFlag(raw string) int {
|
||||
if len(raw) < 4 || raw[0] != 's' {
|
||||
return 0
|
||||
}
|
||||
parts := splitByDelim(raw[2:], raw[1])
|
||||
flags := flagsFromParts(parts, 2)
|
||||
return extractNumber(flags)
|
||||
}
|
||||
|
||||
// extractNumber returns the first contiguous positive integer found in a string, or 0.
|
||||
// Ignores content inside {attrs} blocks.
|
||||
func extractNumber(s string) int {
|
||||
if idx := strings.Index(s, "{"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
// Find first digit run
|
||||
start := -1
|
||||
for i, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
if start < 0 {
|
||||
start = i
|
||||
}
|
||||
} else if start >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return 0
|
||||
}
|
||||
end := start
|
||||
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
n, err := strconv.Atoi(s[start:end])
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseDCommand parses a delete command: d/pattern/ or d/pattern/flags
|
||||
// Deletes all text matching the pattern (entire line containing match).
|
||||
func parseDCommand(raw string) (sedExpr, error) {
|
||||
if len(raw) < 3 || raw[0] != 'd' {
|
||||
return sedExpr{}, fmt.Errorf("invalid delete command (expected d/pattern/)")
|
||||
}
|
||||
parts := splitByDelim(raw[2:], raw[1])
|
||||
if len(parts) < 1 || parts[0] == "" {
|
||||
return sedExpr{}, fmt.Errorf("invalid delete command (empty pattern)")
|
||||
}
|
||||
pattern := applyRegexFlags(parts[0], flagsFromParts(parts, 1))
|
||||
return sedExpr{pattern: pattern, command: 'd'}, nil
|
||||
}
|
||||
|
||||
// parseAICommand parses append/insert commands: a/pattern/text/ or i/pattern/text/
|
||||
// 'a' appends text after the matched line; 'i' inserts text before the matched line.
|
||||
func parseAICommand(raw string, cmd byte) (sedExpr, error) {
|
||||
if len(raw) < 3 || raw[0] != cmd {
|
||||
return sedExpr{}, fmt.Errorf("invalid %c command", cmd)
|
||||
}
|
||||
parts := splitByDelim(raw[2:], raw[1])
|
||||
if len(parts) < 2 {
|
||||
return sedExpr{}, fmt.Errorf("invalid %c command (expected %c/pattern/text/)", cmd, cmd)
|
||||
}
|
||||
pattern := applyRegexFlags(parts[0], flagsFromParts(parts, 2))
|
||||
return sedExpr{pattern: pattern, replacement: parts[1], command: cmd}, nil
|
||||
}
|
||||
|
||||
// flagsFromParts returns the flags string from parts[idx] if it exists, or empty string.
|
||||
func flagsFromParts(parts []string, idx int) string {
|
||||
if idx < len(parts) {
|
||||
return parts[idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyRegexFlags prepends Go regex flag syntax for i (case-insensitive) and m (multiline).
|
||||
func applyRegexFlags(pattern, flags string) string {
|
||||
// Strip {attrs} block from flags before checking flag characters
|
||||
if idx := strings.Index(flags, "{"); idx >= 0 {
|
||||
flags = flags[:idx]
|
||||
}
|
||||
if strings.Contains(flags, "i") {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
if strings.Contains(flags, "m") {
|
||||
pattern = "(?m)" + pattern
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
|
||||
// parseYCommand parses a transliterate command: y/source/dest/
|
||||
// Each character in source is replaced by the corresponding character in dest.
|
||||
func parseYCommand(raw string) (sedExpr, error) {
|
||||
if len(raw) < 3 || raw[0] != 'y' {
|
||||
return sedExpr{}, fmt.Errorf("invalid transliterate command (expected y/source/dest/)")
|
||||
}
|
||||
parts := splitByDelim(raw[2:], raw[1])
|
||||
if len(parts) < 2 {
|
||||
return sedExpr{}, fmt.Errorf("invalid transliterate command (expected y/source/dest/)")
|
||||
}
|
||||
source := parts[0]
|
||||
dest := parts[1]
|
||||
if len([]rune(source)) != len([]rune(dest)) {
|
||||
return sedExpr{}, fmt.Errorf("transliterate: source and dest must have same length (%d vs %d)", len([]rune(source)), len([]rune(dest)))
|
||||
}
|
||||
if source == "" {
|
||||
return sedExpr{}, fmt.Errorf("transliterate: empty source")
|
||||
}
|
||||
return sedExpr{pattern: source, replacement: dest, command: 'y'}, nil
|
||||
}
|
||||
|
||||
// splitByDelim splits a string by an unescaped delimiter character.
|
||||
func splitByDelim(s string, delim byte) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch {
|
||||
case s[i] == '\\' && i+1 < len(s) && s[i+1] == delim:
|
||||
current.WriteByte(delim)
|
||||
i++
|
||||
case s[i] == delim:
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
parts = append(parts, current.String())
|
||||
return parts
|
||||
}
|
||||
|
||||
// isAlphanumeric returns true if b is a letter or digit.
|
||||
func isAlphanumeric(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
// (Legacy parseAttrsFromRaw, parseAttrs, sedAttrs removed — use brace syntax instead)
|
||||
84
internal/cmd/docs_sed_retry.go
Normal file
84
internal/cmd/docs_sed_retry.go
Normal file
@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gapi "google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRetries = 5
|
||||
baseDelay = 1 * time.Second
|
||||
maxDelay = 30 * time.Second
|
||||
)
|
||||
|
||||
// retryOnQuota retries fn on 429 (rate limit) and 500/503 (transient server) errors
|
||||
// with exponential backoff + jitter.
|
||||
func retryOnQuota(ctx context.Context, fn func() error) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
// Check if retryable
|
||||
if !isRetryableError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted attempts
|
||||
if attempt == maxRetries {
|
||||
return fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
// Add jitter: 50-100% of delay (crypto/rand for linter compliance)
|
||||
var randBuf [8]byte
|
||||
_, _ = rand.Read(randBuf[:])
|
||||
halfDelay := int64(delay / 2)
|
||||
var jitter time.Duration
|
||||
if halfDelay > 0 {
|
||||
jitter = time.Duration(binary.LittleEndian.Uint64(randBuf[:]) % uint64(halfDelay)) //nolint:gosec // jitter value is bounded
|
||||
}
|
||||
delay = delay/2 + jitter
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// isRetryableError returns true for transient Google API errors (429, 500, 502, 503)
|
||||
// that are safe to retry with exponential backoff.
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var apiErr *gapi.Error
|
||||
if ok := errors.As(err, &apiErr); ok {
|
||||
switch apiErr.Code {
|
||||
case 429: // rate limit
|
||||
return true
|
||||
case 500, 502, 503: // transient server errors
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Also check for string match as fallback (some errors don't use googleapi.Error)
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "rateLimitExceeded") || strings.Contains(errStr, "429")
|
||||
}
|
||||
317
internal/cmd/docs_sed_table_cells.go
Normal file
317
internal/cmd/docs_sed_table_cells.go
Normal file
@ -0,0 +1,317 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// runTableCellReplace replaces content in a specific table cell, handling both
|
||||
// whole-cell and sub-pattern replacements with optional formatting.
|
||||
func (c *DocsSedCmd) runTableCellReplace(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
ref := expr.cellRef
|
||||
|
||||
// Route row/col operations to dedicated handler
|
||||
if ref.rowOp != "" || ref.colOp != "" {
|
||||
return c.runTableRowColOp(ctx, u, account, id, expr)
|
||||
}
|
||||
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle wildcard ranges: iterate over matching cells
|
||||
if ref.row == 0 || ref.col == 0 {
|
||||
return c.runTableWildcardReplace(ctx, docsSvc, u, id, doc, expr)
|
||||
}
|
||||
|
||||
cell, err := findTableCell(doc, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find table cell: %w", err)
|
||||
}
|
||||
|
||||
cellText, startIdx, endIdx := getCellText(cell)
|
||||
|
||||
var requests []*docs.Request
|
||||
var newText string
|
||||
|
||||
if expr.pattern == "" {
|
||||
// Whole cell replacement: replace entire cell content
|
||||
// Expand ${0} (whole match / sed &) to existing cell text
|
||||
trimmedCell := strings.TrimRight(cellText, "\n")
|
||||
r := literalReplacement(expr.replacement)
|
||||
r = strings.ReplaceAll(r, "${0}", trimmedCell)
|
||||
|
||||
// Parse markdown formatting
|
||||
plainText, formats := parseMarkdownReplacement(r)
|
||||
newText = plainText
|
||||
|
||||
// Strip trailing newline from cell text (cells always end with \n)
|
||||
deleteEnd := endIdx
|
||||
if len(cellText) > 0 && cellText[len(cellText)-1] == '\n' {
|
||||
deleteEnd = endIdx - 1 // keep the trailing newline
|
||||
}
|
||||
requests = append(requests, buildCellReplaceRequests(startIdx, deleteEnd, newText, formats)...)
|
||||
} else {
|
||||
// Sub-pattern replacement within the cell
|
||||
re, reErr := expr.compilePattern()
|
||||
if reErr != nil {
|
||||
return fmt.Errorf("compile pattern: %w", reErr)
|
||||
}
|
||||
|
||||
// Find matches within cell text
|
||||
type cellMatch struct {
|
||||
start, end int64
|
||||
newText string
|
||||
}
|
||||
var matches []cellMatch
|
||||
|
||||
// Use LiteralString unless replacement contains backreferences like ${1}
|
||||
replaceFunc := re.ReplaceAllLiteralString
|
||||
if strings.Contains(expr.replacement, "${") || strings.Contains(expr.replacement, "$\\") {
|
||||
replaceFunc = re.ReplaceAllString
|
||||
}
|
||||
|
||||
if expr.global {
|
||||
results := re.FindAllStringIndex(cellText, -1)
|
||||
for _, loc := range results {
|
||||
oldText := cellText[loc[0]:loc[1]]
|
||||
replaced := replaceFunc(oldText, expr.replacement)
|
||||
matches = append(matches, cellMatch{
|
||||
start: startIdx + int64(loc[0]),
|
||||
end: startIdx + int64(loc[1]),
|
||||
newText: replaced,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
loc := re.FindStringIndex(cellText)
|
||||
if loc != nil {
|
||||
oldText := cellText[loc[0]:loc[1]]
|
||||
replaced := replaceFunc(oldText, expr.replacement)
|
||||
matches = append(matches, cellMatch{
|
||||
start: startIdx + int64(loc[0]),
|
||||
end: startIdx + int64(loc[1]),
|
||||
newText: replaced,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build requests in reverse order
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
m := matches[i]
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: m.start,
|
||||
EndIndex: m.end,
|
||||
},
|
||||
},
|
||||
})
|
||||
if m.newText != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: m.start},
|
||||
Text: m.newText,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
|
||||
}
|
||||
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update: %w", err)
|
||||
}
|
||||
|
||||
replaced := 1
|
||||
if expr.pattern != "" && expr.global {
|
||||
replaced = (len(requests) + 1) / 2 // each match = delete + insert
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", replaced})
|
||||
}
|
||||
|
||||
// runBatchCellReplace batches multiple whole-cell replacements for the same table into one API call.
|
||||
func (c *DocsSedCmd) runBatchCellReplace(ctx context.Context, _ *ui.UI, account, id string, exprs []indexedExpr) error {
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type cellOp struct {
|
||||
startIdx, endIdx int64
|
||||
cellText string
|
||||
replacement string
|
||||
}
|
||||
var ops []cellOp
|
||||
|
||||
for _, ie := range exprs {
|
||||
cell, findErr := findTableCell(doc, ie.expr.cellRef)
|
||||
if findErr != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, findErr)
|
||||
}
|
||||
cellText, startIdx, endIdx := getCellText(cell)
|
||||
ops = append(ops, cellOp{startIdx: startIdx, endIdx: endIdx, cellText: cellText, replacement: ie.expr.replacement})
|
||||
}
|
||||
|
||||
// Sort by startIdx descending (reverse document order)
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
return ops[i].startIdx > ops[j].startIdx
|
||||
})
|
||||
|
||||
var requests []*docs.Request
|
||||
for _, op := range ops {
|
||||
trimmedCell := strings.TrimRight(op.cellText, "\n")
|
||||
r := literalReplacement(op.replacement)
|
||||
r = strings.ReplaceAll(r, "${0}", trimmedCell)
|
||||
plainText, formats := parseMarkdownReplacement(r)
|
||||
|
||||
deleteEnd := op.endIdx
|
||||
if len(op.cellText) > 0 && op.cellText[len(op.cellText)-1] == '\n' {
|
||||
deleteEnd = op.endIdx - 1
|
||||
}
|
||||
requests = append(requests, buildCellReplaceRequests(op.startIdx, deleteEnd, plainText, formats)...)
|
||||
}
|
||||
|
||||
if len(requests) > 0 {
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: requests}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch cell update: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runTableWildcardReplace handles cell references with wildcards: |1|[1,*], |1|[*,2], |1|[*,*]
|
||||
func (c *DocsSedCmd) runTableWildcardReplace(ctx context.Context, docsSvc *docs.Service, u *ui.UI, id string, doc *docs.Document, expr sedExpr) error {
|
||||
ref := expr.cellRef
|
||||
|
||||
tables := collectAllTables(doc)
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("document has no tables")
|
||||
}
|
||||
|
||||
ti := ref.tableIndex
|
||||
if ti < 0 {
|
||||
ti = len(tables) + ti + 1
|
||||
}
|
||||
if ti < 1 || ti > len(tables) {
|
||||
return fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tables))
|
||||
}
|
||||
table := tables[ti-1]
|
||||
|
||||
// Collect all matching cells
|
||||
type cellInfo struct {
|
||||
startIdx int64
|
||||
endIdx int64
|
||||
text string
|
||||
}
|
||||
var cells []cellInfo
|
||||
|
||||
for ri, row := range table.TableRows {
|
||||
for ci, cell := range row.TableCells {
|
||||
// Check if this cell matches the wildcard pattern
|
||||
rowMatch := ref.row == 0 || ref.row == ri+1
|
||||
colMatch := ref.col == 0 || ref.col == ci+1
|
||||
if rowMatch && colMatch {
|
||||
text, start, end := getCellText(cell)
|
||||
cells = append(cells, cellInfo{startIdx: start, endIdx: end, text: text})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(cells) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
|
||||
}
|
||||
|
||||
// Build requests in reverse order (to preserve indices)
|
||||
var requests []*docs.Request
|
||||
replaced := 0
|
||||
|
||||
for i := len(cells) - 1; i >= 0; i-- {
|
||||
cell := cells[i]
|
||||
|
||||
// Parse the replacement per-cell (need to expand ${0} with cell content)
|
||||
cellRepl := literalReplacement(expr.replacement)
|
||||
if expr.pattern == "" {
|
||||
// Expand ${0} (sed &) to existing cell text
|
||||
trimmedCell := strings.TrimRight(cell.text, "\n")
|
||||
cellRepl = strings.ReplaceAll(cellRepl, "${0}", trimmedCell)
|
||||
}
|
||||
plainText, formats := parseMarkdownReplacement(cellRepl)
|
||||
|
||||
if expr.pattern == "" {
|
||||
// Whole cell replacement
|
||||
deleteEnd := cell.endIdx
|
||||
if len(cell.text) > 0 && cell.text[len(cell.text)-1] == '\n' {
|
||||
deleteEnd = cell.endIdx - 1
|
||||
}
|
||||
requests = append(requests, buildCellReplaceRequests(cell.startIdx, deleteEnd, plainText, formats)...)
|
||||
replaced++
|
||||
} else {
|
||||
// Sub-pattern replacement within matching cells
|
||||
re, err := expr.compilePattern()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile pattern: %w", err)
|
||||
}
|
||||
results := re.FindAllStringIndex(cell.text, -1)
|
||||
if !expr.global && len(results) > 1 {
|
||||
results = results[:1]
|
||||
}
|
||||
for j := len(results) - 1; j >= 0; j-- {
|
||||
loc := results[j]
|
||||
start := cell.startIdx + int64(loc[0])
|
||||
end := cell.startIdx + int64(loc[1])
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{StartIndex: start, EndIndex: end},
|
||||
},
|
||||
})
|
||||
if plainText != "" {
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: start},
|
||||
Text: plainText,
|
||||
},
|
||||
})
|
||||
}
|
||||
replaced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0})
|
||||
}
|
||||
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (wildcard cell replace): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", replaced})
|
||||
}
|
||||
318
internal/cmd/docs_sed_table_create.go
Normal file
318
internal/cmd/docs_sed_table_create.go
Normal file
@ -0,0 +1,318 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type tableCreateSpec struct {
|
||||
rows int
|
||||
cols int
|
||||
header bool // pin first row as header
|
||||
cells [][]string // optional cell content for pipe-table syntax
|
||||
}
|
||||
|
||||
// parseTableFromPipes detects markdown-style pipe tables like:
|
||||
//
|
||||
// | Name | Role | Status |
|
||||
// | Alice | Engineer | Active |
|
||||
// | Bob | Designer | Active |
|
||||
//
|
||||
// Returns a tableCreateSpec with rows, cols, and cell content filled in.
|
||||
// Returns nil if the replacement is not a pipe table.
|
||||
func parseTableFromPipes(s string) *tableCreateSpec {
|
||||
// Convert escaped newlines to real newlines (sed replacements use \n)
|
||||
s = strings.ReplaceAll(s, "\\n", "\n")
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "|") {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rows [][]string
|
||||
colCount := 0
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
return nil // not a pipe table
|
||||
}
|
||||
|
||||
// Split by | and trim
|
||||
parts := strings.Split(line, "|")
|
||||
var cells []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
// Skip empty parts from leading/trailing |
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// Skip separator rows like |---|---|
|
||||
if strings.Trim(p, "-: ") == "" {
|
||||
cells = nil
|
||||
break
|
||||
}
|
||||
cells = append(cells, p)
|
||||
}
|
||||
if cells == nil {
|
||||
continue // skip separator row
|
||||
}
|
||||
if len(cells) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if colCount == 0 {
|
||||
colCount = len(cells)
|
||||
} else if len(cells) != colCount {
|
||||
// Pad or truncate to match first row
|
||||
for len(cells) < colCount {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
cells = cells[:colCount]
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
|
||||
if len(rows) < 1 || colCount < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tableCreateSpec{
|
||||
rows: len(rows),
|
||||
cols: colCount,
|
||||
cells: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// parseTableCreate checks if a replacement string is a table creation spec like |3x4| or |3x4:header|
|
||||
// Returns nil if it's not a table creation spec.
|
||||
func parseTableCreate(s string) *tableCreateSpec {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 4 || s[0] != '|' || s[len(s)-1] != '|' {
|
||||
return nil
|
||||
}
|
||||
inner := s[1 : len(s)-1]
|
||||
|
||||
// Check for :header suffix
|
||||
header := false
|
||||
if idx := strings.Index(inner, ":"); idx >= 0 {
|
||||
suffix := strings.ToLower(strings.TrimSpace(inner[idx+1:]))
|
||||
if suffix != "header" {
|
||||
return nil
|
||||
}
|
||||
header = true
|
||||
inner = inner[:idx]
|
||||
}
|
||||
|
||||
// Parse RxC
|
||||
inner = strings.ToLower(inner)
|
||||
parts := strings.SplitN(inner, "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
rows, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
cols, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || rows < 1 || cols < 1 || rows > 100 || cols > 26 {
|
||||
return nil
|
||||
}
|
||||
return &tableCreateSpec{rows: rows, cols: cols, header: header}
|
||||
}
|
||||
|
||||
// runTableCreate handles creating a table at the location of matched text
|
||||
// fillTableCells populates a newly-created table with cell content from spec.cells.
|
||||
// nearIndex is the approximate document index where the table was inserted.
|
||||
func (c *DocsSedCmd) fillTableCells(ctx context.Context, docsSvc *docs.Service, id string, nearIndex int64, spec *tableCreateSpec) error {
|
||||
var doc *docs.Document
|
||||
err := retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-fetch document after table create: %w", err)
|
||||
}
|
||||
|
||||
tables := collectAllTables(doc)
|
||||
var targetTable *docs.Table
|
||||
for _, t := range tables {
|
||||
if len(t.TableRows) > 0 && len(t.TableRows[0].TableCells) > 0 {
|
||||
firstCell := t.TableRows[0].TableCells[0]
|
||||
if len(firstCell.Content) > 0 {
|
||||
cellStart := firstCell.Content[0].StartIndex
|
||||
if cellStart >= nearIndex && cellStart <= nearIndex+10 {
|
||||
targetTable = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetTable == nil {
|
||||
return nil // table not found, skip filling
|
||||
}
|
||||
|
||||
var fillRequests []*docs.Request
|
||||
// Iterate in reverse order so indices remain valid after inserts
|
||||
for r := len(targetTable.TableRows) - 1; r >= 0; r-- {
|
||||
row := targetTable.TableRows[r]
|
||||
for ci := len(row.TableCells) - 1; ci >= 0; ci-- {
|
||||
cell := row.TableCells[ci]
|
||||
if r >= len(spec.cells) || ci >= len(spec.cells[r]) {
|
||||
continue
|
||||
}
|
||||
cellText := spec.cells[r][ci]
|
||||
if cellText == "" {
|
||||
continue
|
||||
}
|
||||
if len(cell.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
// In a table cell, the first StructuralElement is a paragraph.
|
||||
// For an empty cell, the paragraph occupies [startIndex, startIndex+1] with just a \n.
|
||||
// We insert at startIndex to place text before the trailing newline.
|
||||
insertIdx := cell.Content[0].StartIndex
|
||||
|
||||
plainText, formats := parseMarkdownReplacement(cellText)
|
||||
|
||||
fillRequests = append(fillRequests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: insertIdx},
|
||||
Text: plainText,
|
||||
},
|
||||
})
|
||||
|
||||
fillRequests = append(fillRequests, buildTextStyleRequests(formats, insertIdx, insertIdx+int64(len(plainText)))...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fillRequests) > 0 {
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: fillRequests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (fill table cells): %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DocsSedCmd) runTableCreate(ctx context.Context, u *ui.UI, account, id string, expr sedExpr, spec *tableCreateSpec) error {
|
||||
re, err := expr.compilePattern()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile pattern: %w", err)
|
||||
}
|
||||
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the placeholder text in the document
|
||||
var matchStart, matchEnd int64
|
||||
found := false
|
||||
|
||||
var walkContent func(content []*docs.StructuralElement)
|
||||
walkContent = func(content []*docs.StructuralElement) {
|
||||
if found {
|
||||
return
|
||||
}
|
||||
for _, elem := range content {
|
||||
if elem.Paragraph != nil {
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun != nil && pe.TextRun.Content != "" {
|
||||
loc := re.FindStringIndex(pe.TextRun.Content)
|
||||
if loc != nil {
|
||||
matchStart = pe.StartIndex + int64(loc[0])
|
||||
matchEnd = pe.StartIndex + int64(loc[1])
|
||||
found = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Walk into table cells too
|
||||
if elem.Table != nil {
|
||||
for _, row := range elem.Table.TableRows {
|
||||
for _, cell := range row.TableCells {
|
||||
walkContent(cell.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.Body != nil {
|
||||
walkContent(doc.Body.Content)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"replaced", 0}, sedOutputKV{"message", "pattern not found"})
|
||||
}
|
||||
|
||||
// Step 1: Delete the placeholder text
|
||||
// Step 2: Insert the table at that position
|
||||
// Note: InsertTableRequest requires the location to be inside a paragraph,
|
||||
// so we insert at the start of the match.
|
||||
var requests []*docs.Request
|
||||
|
||||
// Delete placeholder text
|
||||
if matchStart < matchEnd {
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: matchStart,
|
||||
EndIndex: matchEnd,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert table at the position where placeholder was
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTable: &docs.InsertTableRequest{
|
||||
Location: &docs.Location{Index: matchStart},
|
||||
Rows: int64(spec.rows),
|
||||
Columns: int64(spec.cols),
|
||||
},
|
||||
})
|
||||
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (create table): %w", err)
|
||||
}
|
||||
|
||||
// Fill cells with content if provided (pipe-table syntax)
|
||||
if len(spec.cells) > 0 {
|
||||
if err := c.fillTableCells(ctx, docsSvc, id, matchStart, spec); err != nil {
|
||||
return fmt.Errorf("fill table cells: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
extra := []sedOutputKV{{"created", fmt.Sprintf("%dx%d table", spec.rows, spec.cols)}}
|
||||
if len(spec.cells) > 0 {
|
||||
extra = append(extra, sedOutputKV{"filled", true})
|
||||
}
|
||||
if spec.header {
|
||||
extra = append(extra, sedOutputKV{"header", "true (note: header pinning requires manual step in Docs UI)"})
|
||||
}
|
||||
return sedOutputOK(ctx, u, id, extra...)
|
||||
}
|
||||
266
internal/cmd/docs_sed_table_ops.go
Normal file
266
internal/cmd/docs_sed_table_ops.go
Normal file
@ -0,0 +1,266 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// runTableRowColOp handles row and column operations: insert, delete, and append.
|
||||
func (c *DocsSedCmd) runTableRowColOp(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
ref := expr.cellRef
|
||||
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tablesWithIdx := collectAllTablesWithIndex(doc)
|
||||
if len(tablesWithIdx) == 0 {
|
||||
return fmt.Errorf("document has no tables")
|
||||
}
|
||||
|
||||
// Resolve table index
|
||||
ti := ref.tableIndex
|
||||
if ti < 0 {
|
||||
ti = len(tablesWithIdx) + ti + 1
|
||||
}
|
||||
if ti < 1 || ti > len(tablesWithIdx) {
|
||||
return fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tablesWithIdx))
|
||||
}
|
||||
tw := tablesWithIdx[ti-1]
|
||||
table := tw.table
|
||||
tableStartLoc := &docs.Location{Index: tw.startIdx}
|
||||
|
||||
var requests []*docs.Request
|
||||
opDesc := ""
|
||||
|
||||
if ref.rowOp != "" {
|
||||
numRows := len(table.TableRows)
|
||||
switch ref.rowOp {
|
||||
case opDelete:
|
||||
target := ref.opTarget
|
||||
if target < 0 {
|
||||
target = numRows + target + 1
|
||||
}
|
||||
if target < 1 || target > numRows {
|
||||
return fmt.Errorf("row %d out of range (table has %d rows)", ref.opTarget, numRows)
|
||||
}
|
||||
if numRows <= 1 {
|
||||
return fmt.Errorf("cannot delete the only row in a table")
|
||||
}
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: int64(target - 1),
|
||||
ColumnIndex: 0,
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteTableRow: &docs.DeleteTableRowRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("deleted row %d", target)
|
||||
|
||||
case opInsert:
|
||||
target := ref.opTarget
|
||||
if target < 0 {
|
||||
target = numRows + target + 1
|
||||
}
|
||||
if target < 1 || target > numRows {
|
||||
return fmt.Errorf("row %d out of range for insert (table has %d rows)", ref.opTarget, numRows)
|
||||
}
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: int64(target - 1),
|
||||
ColumnIndex: 0,
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTableRow: &docs.InsertTableRowRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
InsertBelow: false,
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("inserted row before row %d", target)
|
||||
|
||||
case opAppend:
|
||||
lastRow := numRows - 1
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: int64(lastRow),
|
||||
ColumnIndex: 0,
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTableRow: &docs.InsertTableRowRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
InsertBelow: true,
|
||||
},
|
||||
})
|
||||
opDesc = "appended row at end"
|
||||
}
|
||||
}
|
||||
|
||||
if ref.colOp != "" {
|
||||
numCols := 0
|
||||
if len(table.TableRows) > 0 {
|
||||
numCols = len(table.TableRows[0].TableCells)
|
||||
}
|
||||
switch ref.colOp {
|
||||
case opDelete:
|
||||
target := ref.opTarget
|
||||
if target < 0 {
|
||||
target = numCols + target + 1
|
||||
}
|
||||
if target < 1 || target > numCols {
|
||||
return fmt.Errorf("col %d out of range (table has %d columns)", ref.opTarget, numCols)
|
||||
}
|
||||
if numCols <= 1 {
|
||||
return fmt.Errorf("cannot delete the only column in a table")
|
||||
}
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: 0,
|
||||
ColumnIndex: int64(target - 1),
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteTableColumn: &docs.DeleteTableColumnRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("deleted column %d", target)
|
||||
|
||||
case opInsert:
|
||||
target := ref.opTarget
|
||||
if target < 0 {
|
||||
target = numCols + target + 1
|
||||
}
|
||||
if target < 1 || target > numCols {
|
||||
return fmt.Errorf("col %d out of range for insert (table has %d columns)", ref.opTarget, numCols)
|
||||
}
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: 0,
|
||||
ColumnIndex: int64(target - 1),
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTableColumn: &docs.InsertTableColumnRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
InsertRight: false,
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("inserted column before column %d", target)
|
||||
|
||||
case opAppend:
|
||||
lastCol := numCols - 1
|
||||
cellLoc := &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: 0,
|
||||
ColumnIndex: int64(lastCol),
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertTableColumn: &docs.InsertTableColumnRequest{
|
||||
TableCellLocation: cellLoc,
|
||||
InsertRight: true,
|
||||
},
|
||||
})
|
||||
opDesc = "appended column at end"
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return fmt.Errorf("no row/column operation to perform")
|
||||
}
|
||||
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (row/col op): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"op", opDesc})
|
||||
}
|
||||
|
||||
// runTableMerge handles merging or unmerging table cells.
|
||||
// Merge: s/|1|[1,1:2,3]/merge/ — merges cells from [1,1] to [2,3]
|
||||
// Unmerge/split: s/|1|[1,1]/unmerge/ or s/|1|[1,1]/split/
|
||||
func (c *DocsSedCmd) runTableMerge(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
ref := expr.cellRef
|
||||
|
||||
docsSvc, doc, err := fetchDoc(ctx, account, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tablesWithIdx := collectAllTablesWithIndex(doc)
|
||||
tIdx := ref.tableIndex
|
||||
if tIdx < 0 {
|
||||
tIdx = len(tablesWithIdx) + tIdx + 1
|
||||
}
|
||||
if tIdx < 1 || tIdx > len(tablesWithIdx) {
|
||||
return fmt.Errorf("table index %d out of range (have %d tables)", ref.tableIndex, len(tablesWithIdx))
|
||||
}
|
||||
tableStartLoc := &docs.Location{Index: tablesWithIdx[tIdx-1].startIdx}
|
||||
|
||||
repl := strings.TrimSpace(strings.ToLower(expr.replacement))
|
||||
var requests []*docs.Request
|
||||
var opDesc string
|
||||
|
||||
switch repl {
|
||||
case "merge":
|
||||
if ref.endRow == 0 || ref.endCol == 0 {
|
||||
return fmt.Errorf("merge requires a range: |N|[r1,c1:r2,c2]")
|
||||
}
|
||||
requests = append(requests, &docs.Request{
|
||||
MergeTableCells: &docs.MergeTableCellsRequest{
|
||||
TableRange: &docs.TableRange{
|
||||
TableCellLocation: &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: int64(ref.row - 1),
|
||||
ColumnIndex: int64(ref.col - 1),
|
||||
},
|
||||
RowSpan: int64(ref.endRow - ref.row + 1),
|
||||
ColumnSpan: int64(ref.endCol - ref.col + 1),
|
||||
},
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("merged [%d,%d:%d,%d]", ref.row, ref.col, ref.endRow, ref.endCol)
|
||||
case unmergeOp, splitOp:
|
||||
requests = append(requests, &docs.Request{
|
||||
UnmergeTableCells: &docs.UnmergeTableCellsRequest{
|
||||
TableRange: &docs.TableRange{
|
||||
TableCellLocation: &docs.TableCellLocation{
|
||||
TableStartLocation: tableStartLoc,
|
||||
RowIndex: int64(ref.row - 1),
|
||||
ColumnIndex: int64(ref.col - 1),
|
||||
},
|
||||
// For unmerge, span the minimum (1x1) — Google will unmerge whatever
|
||||
// merged region contains this cell
|
||||
RowSpan: 1,
|
||||
ColumnSpan: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
opDesc = fmt.Sprintf("unmerged [%d,%d]", ref.row, ref.col)
|
||||
default:
|
||||
return fmt.Errorf("unknown merge operation %q (expected merge, unmerge, or split)", repl)
|
||||
}
|
||||
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (%s): %w", opDesc, err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"action", opDesc})
|
||||
}
|
||||
429
internal/cmd/docs_sed_tables.go
Normal file
429
internal/cmd/docs_sed_tables.go
Normal file
@ -0,0 +1,429 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type tableCellRef struct {
|
||||
tableIndex int // 1-indexed, negative means from end (-1 = last)
|
||||
row int // 1-indexed, 0 means wildcard (*)
|
||||
col int // 1-indexed, 0 means wildcard (*)
|
||||
subPattern string // optional pattern to match within the cell
|
||||
|
||||
// Row/column operations
|
||||
rowOp string // "delete", "insert" — set when [row:N] or [row:+N] syntax used
|
||||
colOp string // "delete", "insert" — set when [col:N] or [col:+N] syntax used
|
||||
opTarget int // target row/col index (1-indexed, negative from end)
|
||||
|
||||
// Merge range: [r1,c1:r2,c2] → merge cells from (row,col) to (endRow,endCol)
|
||||
endRow int // 0 means no merge range
|
||||
endCol int
|
||||
}
|
||||
|
||||
// parseTableCellRef parses a table cell reference like |1|[2,3] or |1|[A1] or |-1|[1,1]
|
||||
// Returns nil if the string is not a table cell reference.
|
||||
// Optionally parses a trailing :pattern for within-cell matching.
|
||||
func parseTableCellRef(s string) *tableCellRef {
|
||||
// Must start with |
|
||||
if len(s) == 0 || s[0] != '|' {
|
||||
return nil
|
||||
}
|
||||
// Find second |
|
||||
idx := strings.Index(s[1:], "|")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
tableStr := s[1 : 1+idx]
|
||||
rest := s[1+idx+1:] // after second |
|
||||
|
||||
tableIdx, err := strconv.Atoi(tableStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must have [...]
|
||||
if len(rest) == 0 || rest[0] != '[' {
|
||||
return nil
|
||||
}
|
||||
bracketEnd := strings.Index(rest, "]")
|
||||
if bracketEnd < 0 {
|
||||
return nil
|
||||
}
|
||||
cellStr := rest[1:bracketEnd]
|
||||
after := rest[bracketEnd+1:]
|
||||
|
||||
// Check for row/col operations: [row:N], [row:+N], [col:N], [col:+N]
|
||||
if strings.HasPrefix(cellStr, "row:") || strings.HasPrefix(cellStr, "col:") {
|
||||
isRow := strings.HasPrefix(cellStr, "row:")
|
||||
valStr := cellStr[4:]
|
||||
ref := &tableCellRef{tableIndex: tableIdx}
|
||||
|
||||
if strings.HasPrefix(valStr, "+") {
|
||||
// Insert operation
|
||||
n, err := strconv.Atoi(valStr[1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if isRow {
|
||||
ref.rowOp = opInsert
|
||||
ref.opTarget = n
|
||||
} else {
|
||||
ref.colOp = opInsert
|
||||
ref.opTarget = n
|
||||
}
|
||||
} else {
|
||||
// Delete operation (or special like $+ for append)
|
||||
if valStr == "$+" {
|
||||
// Append at end
|
||||
if isRow {
|
||||
ref.rowOp = opAppend
|
||||
} else {
|
||||
ref.colOp = opAppend
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(valStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if isRow {
|
||||
ref.rowOp = opDelete
|
||||
ref.opTarget = n
|
||||
} else {
|
||||
ref.colOp = opDelete
|
||||
ref.opTarget = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
var row, col int
|
||||
var endRow, endCol int
|
||||
|
||||
// Check for merge range syntax: R1,C1:R2,C2
|
||||
if colonIdx := strings.Index(cellStr, ":"); colonIdx > 0 {
|
||||
startPart := cellStr[:colonIdx]
|
||||
endPart := cellStr[colonIdx+1:]
|
||||
|
||||
// Parse start cell
|
||||
startParts := strings.SplitN(startPart, ",", 2)
|
||||
if len(startParts) != 2 {
|
||||
return nil
|
||||
}
|
||||
r, err := strconv.Atoi(strings.TrimSpace(startParts[0]))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c, err2 := strconv.Atoi(strings.TrimSpace(startParts[1]))
|
||||
if err2 != nil {
|
||||
return nil
|
||||
}
|
||||
row, col = r, c
|
||||
|
||||
// Parse end cell
|
||||
endParts := strings.SplitN(endPart, ",", 2)
|
||||
if len(endParts) != 2 {
|
||||
return nil
|
||||
}
|
||||
er, err3 := strconv.Atoi(strings.TrimSpace(endParts[0]))
|
||||
if err3 != nil {
|
||||
return nil
|
||||
}
|
||||
ec, err4 := strconv.Atoi(strings.TrimSpace(endParts[1]))
|
||||
if err4 != nil {
|
||||
return nil
|
||||
}
|
||||
endRow, endCol = er, ec
|
||||
} else if parts := strings.SplitN(cellStr, ",", 2); len(parts) == 2 {
|
||||
// Try R,C format (with wildcard support)
|
||||
rStr := strings.TrimSpace(parts[0])
|
||||
cStr := strings.TrimSpace(parts[1])
|
||||
|
||||
switch {
|
||||
case rStr == "*":
|
||||
row = 0 // wildcard
|
||||
case strings.HasPrefix(rStr, "+"):
|
||||
// +N means append row
|
||||
ref := &tableCellRef{tableIndex: tableIdx, rowOp: "append"}
|
||||
n, err := strconv.Atoi(rStr[1:])
|
||||
if err == nil {
|
||||
ref.opTarget = n
|
||||
}
|
||||
// Parse col for the append target
|
||||
if cStr == "*" {
|
||||
ref.col = 0
|
||||
} else {
|
||||
c, err := strconv.Atoi(cStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ref.col = c
|
||||
}
|
||||
return ref
|
||||
default:
|
||||
r, err := strconv.Atoi(rStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
row = r
|
||||
}
|
||||
|
||||
switch {
|
||||
case cStr == "*":
|
||||
col = 0 // wildcard
|
||||
case strings.HasPrefix(cStr, "+"):
|
||||
// +N means append column
|
||||
ref := &tableCellRef{tableIndex: tableIdx, colOp: "append"}
|
||||
n, err := strconv.Atoi(cStr[1:])
|
||||
if err == nil {
|
||||
ref.opTarget = n
|
||||
}
|
||||
ref.row = row
|
||||
return ref
|
||||
default:
|
||||
c, err := strconv.Atoi(cStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
col = c
|
||||
}
|
||||
} else {
|
||||
// Try Excel-style A1
|
||||
r, c, ok := parseExcelRef(cellStr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
row, col = r, c
|
||||
}
|
||||
|
||||
ref := &tableCellRef{tableIndex: tableIdx, row: row, col: col, endRow: endRow, endCol: endCol}
|
||||
|
||||
// Optional :pattern
|
||||
if strings.HasPrefix(after, ":") {
|
||||
ref.subPattern = after[1:]
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
// parseExcelRef parses an Excel-style cell reference like "A1", "B2", "AA10"
|
||||
// Returns (row, col, ok) where row and col are 1-indexed.
|
||||
func parseExcelRef(s string) (row, col int, ok bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Split into letter part and number part
|
||||
i := 0
|
||||
for i < len(s) && ((s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i == len(s) {
|
||||
return 0, 0, false
|
||||
}
|
||||
letters := strings.ToUpper(s[:i])
|
||||
numStr := s[i:]
|
||||
r, err := strconv.Atoi(numStr)
|
||||
if err != nil || r < 1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Convert letters to column number
|
||||
c := 0
|
||||
for _, ch := range letters {
|
||||
c = c*26 + int(ch-'A') + 1
|
||||
}
|
||||
return r, c, true
|
||||
}
|
||||
|
||||
func (c *DocsSedCmd) runTableOp(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docs service: %w", err)
|
||||
}
|
||||
|
||||
var doc *docs.Document
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
var e error
|
||||
doc, e = docsSvc.Documents.Get(id).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
|
||||
// Collect all tables with their structural element indices
|
||||
type tableInfo struct {
|
||||
table *docs.Table
|
||||
startIdx int64
|
||||
endIdx int64
|
||||
}
|
||||
var tables []tableInfo
|
||||
|
||||
if doc.Body != nil {
|
||||
for _, elem := range doc.Body.Content {
|
||||
if elem.Table != nil {
|
||||
tables = append(tables, tableInfo{
|
||||
table: elem.Table,
|
||||
startIdx: elem.StartIndex,
|
||||
endIdx: elem.EndIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("document has no tables")
|
||||
}
|
||||
|
||||
// Resolve which tables to target
|
||||
var targets []tableInfo
|
||||
tIdx := expr.tableRef
|
||||
if tIdx == math.MinInt32 {
|
||||
// |*| — all tables
|
||||
targets = tables
|
||||
} else {
|
||||
resolved := tIdx
|
||||
if resolved < 0 {
|
||||
resolved = len(tables) + resolved + 1
|
||||
}
|
||||
if resolved < 1 || resolved > len(tables) {
|
||||
return fmt.Errorf("table %d out of range (document has %d tables)", tIdx, len(tables))
|
||||
}
|
||||
targets = []tableInfo{tables[resolved-1]}
|
||||
}
|
||||
|
||||
// Handle the operation based on replacement
|
||||
replacement := strings.TrimSpace(expr.replacement)
|
||||
|
||||
if replacement == "" {
|
||||
// DELETE tables — process in reverse order to preserve indices
|
||||
var requests []*docs.Request
|
||||
for i := len(targets) - 1; i >= 0; i-- {
|
||||
t := targets[i]
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: t.startIdx,
|
||||
EndIndex: t.endIdx,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err = retryOnQuota(ctx, func() error {
|
||||
_, e := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
|
||||
Requests: requests,
|
||||
}).Context(ctx).Do()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update (delete table): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{"deleted", fmt.Sprintf("%d table(s)", len(targets))})
|
||||
}
|
||||
|
||||
// Future: handle pin=N, other table-level operations
|
||||
return fmt.Errorf("unsupported table operation: %q (expected empty replacement for delete)", replacement)
|
||||
}
|
||||
|
||||
type tableWithIndex struct {
|
||||
table *docs.Table
|
||||
startIdx int64
|
||||
}
|
||||
|
||||
// collectAllTablesWithIndex returns all tables in the document along with their
|
||||
// structural element index, used for operations that need positional context.
|
||||
func collectAllTablesWithIndex(doc *docs.Document) []tableWithIndex {
|
||||
var tables []tableWithIndex
|
||||
var walkContent func(content []*docs.StructuralElement)
|
||||
walkContent = func(content []*docs.StructuralElement) {
|
||||
for _, elem := range content {
|
||||
if elem.Table != nil {
|
||||
tables = append(tables, tableWithIndex{table: elem.Table, startIdx: elem.StartIndex})
|
||||
for _, row := range elem.Table.TableRows {
|
||||
for _, cell := range row.TableCells {
|
||||
walkContent(cell.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.Body != nil {
|
||||
walkContent(doc.Body.Content)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
// collectAllTables returns all tables in the document in order of appearance.
|
||||
func collectAllTables(doc *docs.Document) []*docs.Table {
|
||||
withIdx := collectAllTablesWithIndex(doc)
|
||||
tables := make([]*docs.Table, len(withIdx))
|
||||
for i, t := range withIdx {
|
||||
tables[i] = t.table
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
// findTableCell locates a specific table cell in the document.
|
||||
// Tables are numbered in document order including nested tables.
|
||||
func findTableCell(doc *docs.Document, ref *tableCellRef) (*docs.TableCell, error) {
|
||||
tables := collectAllTables(doc)
|
||||
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("document has no tables")
|
||||
}
|
||||
|
||||
// Resolve table index
|
||||
ti := ref.tableIndex
|
||||
if ti < 0 {
|
||||
ti = len(tables) + ti + 1 // -1 → last
|
||||
}
|
||||
if ti < 1 || ti > len(tables) {
|
||||
return nil, fmt.Errorf("table %d out of range (document has %d tables)", ref.tableIndex, len(tables))
|
||||
}
|
||||
table := tables[ti-1]
|
||||
|
||||
// Resolve row
|
||||
if ref.row < 1 || ref.row > len(table.TableRows) {
|
||||
return nil, fmt.Errorf("row %d out of range (table has %d rows)", ref.row, len(table.TableRows))
|
||||
}
|
||||
row := table.TableRows[ref.row-1]
|
||||
|
||||
// Resolve col
|
||||
if ref.col < 1 || ref.col > len(row.TableCells) {
|
||||
return nil, fmt.Errorf("col %d out of range (row has %d columns)", ref.col, len(row.TableCells))
|
||||
}
|
||||
return row.TableCells[ref.col-1], nil
|
||||
}
|
||||
|
||||
// getCellText extracts the plain text content from a table cell.
|
||||
// Returns the concatenated text, the start index of the first text run,
|
||||
// and the end index of the last text run.
|
||||
func getCellText(cell *docs.TableCell) (text string, startIdx int64, endIdx int64) {
|
||||
var b strings.Builder
|
||||
for _, elem := range cell.Content {
|
||||
if elem.Paragraph != nil {
|
||||
for _, pe := range elem.Paragraph.Elements {
|
||||
if pe.TextRun != nil {
|
||||
b.WriteString(pe.TextRun.Content)
|
||||
if startIdx == 0 && pe.StartIndex > 0 {
|
||||
startIdx = pe.StartIndex
|
||||
}
|
||||
endIdx = pe.EndIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), startIdx, endIdx
|
||||
}
|
||||
|
||||
// runTableCellReplace handles sed expressions targeting specific table cells
|
||||
Loading…
Reference in New Issue
Block a user