feat(sedmat): add paragraph addressing and structure introspection
Add paragraph-number addressing (5d, 3s/.*/text/, $a/text/) and `docs structure` / `docs cat -N` commands for paragraph-level document manipulation. New commands: - `docs structure` — numbered paragraph list with types (text + JSON) - `docs cat -N` — cat with [N] paragraph prefixes Address syntax for `docs sed`: - Nd (delete paragraph N), N,Md (range delete), $d (last) - Ns/pat/repl/ (substitute within paragraph N) - Na/text/ (append after N), Ni/text/ (insert before N) - --tab flag for multi-tab document support Testing: - 24 new unit tests covering parseAddress (13 cases), parseFullExpr_Addressed (10 cases), resolveAddress (6 cases), and buildParagraphMap (8 cases). All pass. - Manual testing against live Google Docs verified: structure, cat -N, addressed substitute, delete, append, insert, dollar addressing, and range delete. - Bug found and fixed during manual testing: addressed a/text/ and i/text/ were parsed by parseAICommand (expects a/pat/text/) which put text in the pattern field instead of replacement, producing empty paragraphs. Added parseAddressedAICommand for the single-field addressed form. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb16e4f42f
commit
3e85dcf8ba
@ -309,6 +309,48 @@ s/![logo]/!(https:\/\/new-logo.png)/ # match by alt text
|
||||
|
||||
---
|
||||
|
||||
## Paragraph Addressing
|
||||
|
||||
Target specific paragraphs by number using address prefixes. Use `gog docs structure` to see paragraph numbers.
|
||||
|
||||
```bash
|
||||
# Introspection — see paragraph numbers, types, and content
|
||||
gog docs structure <DOC_ID> # show numbered structure
|
||||
gog docs cat <DOC_ID> -N # cat with [N] prefixes
|
||||
|
||||
# Delete by paragraph number
|
||||
gog docs sed <DOC_ID> '5d' # delete paragraph 5
|
||||
gog docs sed <DOC_ID> '3,7d' # delete paragraphs 3-7
|
||||
gog docs sed <DOC_ID> '$d' # delete last paragraph
|
||||
|
||||
# Substitute within addressed paragraphs
|
||||
gog docs sed <DOC_ID> '5s/.*/New text/' # replace all text in paragraph 5
|
||||
gog docs sed <DOC_ID> '3,7s/old/new/g' # replace within paragraphs 3-7
|
||||
|
||||
# Insert/Append around addressed paragraphs
|
||||
gog docs sed <DOC_ID> '5a/New line/' # append after paragraph 5
|
||||
gog docs sed <DOC_ID> '3i/Before text/' # insert before paragraph 3
|
||||
gog docs sed <DOC_ID> '$a/Last line/' # append after last paragraph
|
||||
```
|
||||
|
||||
### Address Syntax
|
||||
|
||||
| Address | Meaning |
|
||||
|---------|---------|
|
||||
| `N` | Paragraph number N (1-based) |
|
||||
| `N,M` | Range from paragraph N to M |
|
||||
| `$` | Last paragraph |
|
||||
| `N,$` | From paragraph N to end |
|
||||
|
||||
### Multi-Tab Support
|
||||
|
||||
```bash
|
||||
gog docs structure <DOC_ID> --tab "Sheet1"
|
||||
gog docs sed <DOC_ID> --tab "Sheet1" '3d'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch Mode
|
||||
|
||||
Create a `.sed` file with one expression per line. Comments start with `#`.
|
||||
|
||||
@ -38,6 +38,7 @@ type DocsCmd struct {
|
||||
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"`
|
||||
Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"`
|
||||
}
|
||||
type DocsExportCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
@ -276,6 +277,7 @@ type DocsCatCmd struct {
|
||||
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"`
|
||||
Numbered bool `name:"numbered" short:"N" help:"Prefix each paragraph with its number"`
|
||||
}
|
||||
|
||||
func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
@ -340,6 +342,10 @@ func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return errors.New("doc not found")
|
||||
}
|
||||
|
||||
if c.Numbered {
|
||||
return c.printNumbered(ctx, doc, "")
|
||||
}
|
||||
|
||||
text := docsPlainText(doc, c.MaxBytes)
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -545,6 +551,9 @@ func (c *DocsCatCmd) runWithTabs(ctx context.Context, svc *docs.Service, id stri
|
||||
if tab == nil {
|
||||
return fmt.Errorf("tab not found: %s", c.Tab)
|
||||
}
|
||||
if c.Numbered {
|
||||
return c.printNumbered(ctx, doc, c.Tab)
|
||||
}
|
||||
text := tabPlainText(tab, c.MaxBytes)
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
@ -937,6 +946,99 @@ func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return sedCmd.Run(ctx, flags)
|
||||
}
|
||||
|
||||
// --- Structure / Numbered commands ---
|
||||
|
||||
// DocsStructureCmd displays document structure with numbered paragraphs.
|
||||
type DocsStructureCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
Tab string `name:"tab" help:"Tab title or ID (omit for default)"`
|
||||
}
|
||||
|
||||
func (c *DocsStructureCmd) 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")
|
||||
}
|
||||
|
||||
svc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
getCall := svc.Documents.Get(id)
|
||||
if c.Tab != "" {
|
||||
getCall = getCall.IncludeTabsContent(true)
|
||||
}
|
||||
doc, err := getCall.Context(ctx).Do()
|
||||
if err != nil {
|
||||
if isDocsNotFound(err) {
|
||||
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if doc == nil {
|
||||
return errors.New("doc not found")
|
||||
}
|
||||
|
||||
pm, err := buildParagraphMap(doc, c.Tab)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, pm)
|
||||
}
|
||||
|
||||
u.Out().Printf(" # TYPE CONTENT")
|
||||
for _, p := range pm.Paragraphs {
|
||||
prefix := ""
|
||||
if p.IsBullet {
|
||||
prefix = strings.Repeat(" ", p.NestLevel) + "* "
|
||||
}
|
||||
|
||||
text := p.Text
|
||||
if len(text) > 60 {
|
||||
text = text[:57] + "..."
|
||||
}
|
||||
|
||||
if p.ElemType == "table" {
|
||||
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
|
||||
}
|
||||
|
||||
u.Out().Printf("%2d %-18s %s%s", p.Num, p.Type, prefix, text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printNumbered prints document content with [N] paragraph number prefixes.
|
||||
func (c *DocsCatCmd) printNumbered(ctx context.Context, doc *docs.Document, tabID string) error {
|
||||
pm, err := buildParagraphMap(doc, tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, pm)
|
||||
}
|
||||
|
||||
for _, p := range pm.Paragraphs {
|
||||
text := p.Text
|
||||
if p.ElemType == "table" {
|
||||
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
|
||||
}
|
||||
if _, err := fmt.Fprintf(os.Stdout, "[%d] %s\n", p.Num, text); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DocsFindReplaceCmd struct {
|
||||
DocID string `arg:"" name:"docId" help:"Doc ID"`
|
||||
Find string `arg:"" name:"find" help:"Text to find"`
|
||||
|
||||
209
internal/cmd/docs_paragraphs.go
Normal file
209
internal/cmd/docs_paragraphs.go
Normal file
@ -0,0 +1,209 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
)
|
||||
|
||||
// docParagraph represents a single numbered element in a Google Doc's structure.
|
||||
type docParagraph struct {
|
||||
Num int `json:"num"`
|
||||
StartIndex int64 `json:"startIndex"`
|
||||
EndIndex int64 `json:"endIndex"`
|
||||
Type string `json:"type"`
|
||||
IsBullet bool `json:"bullet"`
|
||||
NestLevel int `json:"nestLevel,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ElemType string `json:"elemType"` // "paragraph", "table", "toc", "sectionBreak"
|
||||
TableRows int `json:"tableRows,omitempty"`
|
||||
TableCols int `json:"tableCols,omitempty"`
|
||||
}
|
||||
|
||||
// paragraphMap holds the structured view of a Google Doc's content.
|
||||
type paragraphMap struct {
|
||||
DocumentID string `json:"documentId"`
|
||||
RevisionID string `json:"revisionId"`
|
||||
TabID string `json:"tab,omitempty"`
|
||||
Paragraphs []docParagraph `json:"paragraphs"`
|
||||
}
|
||||
|
||||
// buildParagraphMap traverses the document body and numbers each paragraph
|
||||
// and table sequentially (1-based). The initial SectionBreak at index 0 is
|
||||
// skipped as it is not user-editable.
|
||||
func buildParagraphMap(doc *docs.Document, tabID string) (*paragraphMap, error) {
|
||||
if doc == nil {
|
||||
return nil, fmt.Errorf("nil document")
|
||||
}
|
||||
|
||||
var content []*docs.StructuralElement
|
||||
var revisionID string
|
||||
|
||||
if tabID != "" && len(doc.Tabs) > 0 {
|
||||
tabs := flattenTabs(doc.Tabs)
|
||||
tab := findTab(tabs, tabID)
|
||||
if tab == nil {
|
||||
return nil, fmt.Errorf("tab not found: %s", tabID)
|
||||
}
|
||||
if tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
|
||||
return nil, fmt.Errorf("tab has no content: %s", tabID)
|
||||
}
|
||||
content = tab.DocumentTab.Body.Content
|
||||
if tab.TabProperties != nil {
|
||||
tabID = tab.TabProperties.TabId
|
||||
}
|
||||
} else {
|
||||
if doc.Body == nil {
|
||||
return nil, fmt.Errorf("document has no body")
|
||||
}
|
||||
content = doc.Body.Content
|
||||
}
|
||||
revisionID = doc.RevisionId
|
||||
|
||||
pm := ¶graphMap{
|
||||
DocumentID: doc.DocumentId,
|
||||
RevisionID: revisionID,
|
||||
TabID: tabID,
|
||||
}
|
||||
|
||||
num := 0
|
||||
for _, el := range content {
|
||||
if el == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case el.SectionBreak != nil:
|
||||
// Skip section breaks — not user-editable.
|
||||
continue
|
||||
|
||||
case el.Paragraph != nil:
|
||||
num++
|
||||
dp := docParagraph{
|
||||
Num: num,
|
||||
StartIndex: el.StartIndex,
|
||||
EndIndex: el.EndIndex,
|
||||
ElemType: "paragraph",
|
||||
Text: paragraphText(el.Paragraph),
|
||||
}
|
||||
|
||||
// Extract named style type.
|
||||
if el.Paragraph.ParagraphStyle != nil {
|
||||
dp.Type = el.Paragraph.ParagraphStyle.NamedStyleType
|
||||
}
|
||||
if dp.Type == "" {
|
||||
dp.Type = "NORMAL_TEXT"
|
||||
}
|
||||
|
||||
// Extract bullet info.
|
||||
if el.Paragraph.Bullet != nil {
|
||||
dp.IsBullet = true
|
||||
dp.NestLevel = int(el.Paragraph.Bullet.NestingLevel)
|
||||
}
|
||||
|
||||
pm.Paragraphs = append(pm.Paragraphs, dp)
|
||||
|
||||
case el.Table != nil:
|
||||
num++
|
||||
rows := len(el.Table.TableRows)
|
||||
cols := 0
|
||||
if rows > 0 && len(el.Table.TableRows[0].TableCells) > 0 {
|
||||
cols = len(el.Table.TableRows[0].TableCells)
|
||||
}
|
||||
dp := docParagraph{
|
||||
Num: num,
|
||||
StartIndex: el.StartIndex,
|
||||
EndIndex: el.EndIndex,
|
||||
Type: "TABLE",
|
||||
ElemType: "table",
|
||||
Text: tablePreviewText(el.Table),
|
||||
TableRows: rows,
|
||||
TableCols: cols,
|
||||
}
|
||||
pm.Paragraphs = append(pm.Paragraphs, dp)
|
||||
|
||||
case el.TableOfContents != nil:
|
||||
num++
|
||||
dp := docParagraph{
|
||||
Num: num,
|
||||
StartIndex: el.StartIndex,
|
||||
EndIndex: el.EndIndex,
|
||||
Type: "TABLE_OF_CONTENTS",
|
||||
ElemType: "toc",
|
||||
Text: "[table of contents]",
|
||||
}
|
||||
pm.Paragraphs = append(pm.Paragraphs, dp)
|
||||
}
|
||||
}
|
||||
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
// paragraphText extracts the plain text from a Paragraph element.
|
||||
func paragraphText(p *docs.Paragraph) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, elem := range p.Elements {
|
||||
if elem.TextRun != nil {
|
||||
sb.WriteString(elem.TextRun.Content)
|
||||
}
|
||||
}
|
||||
// Trim the trailing newline that Google Docs adds to every paragraph.
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// get returns the paragraph at the given 1-based number.
|
||||
func (pm *paragraphMap) get(num int) (*docParagraph, error) {
|
||||
if num < 1 || num > len(pm.Paragraphs) {
|
||||
return nil, fmt.Errorf("paragraph %d out of range (document has %d paragraphs)", num, len(pm.Paragraphs))
|
||||
}
|
||||
return &pm.Paragraphs[num-1], nil
|
||||
}
|
||||
|
||||
// fetchAndBuildMap fetches the document and builds a paragraph map.
|
||||
func fetchAndBuildMap(ctx context.Context, svc *docs.Service, docID, tabID string) (*paragraphMap, error) {
|
||||
getCall := svc.Documents.Get(docID)
|
||||
if tabID != "" {
|
||||
getCall = getCall.IncludeTabsContent(true)
|
||||
}
|
||||
doc, err := getCall.Context(ctx).Do()
|
||||
if err != nil {
|
||||
if isDocsNotFound(err) {
|
||||
return nil, fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, errors.New("doc not found")
|
||||
}
|
||||
|
||||
return buildParagraphMap(doc, tabID)
|
||||
}
|
||||
|
||||
// tablePreviewText returns a short preview of the table content.
|
||||
func tablePreviewText(t *docs.Table) string {
|
||||
if t == nil || len(t.TableRows) == 0 {
|
||||
return "[empty table]"
|
||||
}
|
||||
// Show first row cells as a preview.
|
||||
var cells []string
|
||||
for _, cell := range t.TableRows[0].TableCells {
|
||||
var text strings.Builder
|
||||
for _, el := range cell.Content {
|
||||
if el.Paragraph != nil {
|
||||
text.WriteString(paragraphText(el.Paragraph))
|
||||
}
|
||||
}
|
||||
cells = append(cells, strings.TrimSpace(text.String()))
|
||||
}
|
||||
preview := strings.Join(cells, " | ")
|
||||
if len(preview) > 60 {
|
||||
preview = preview[:57] + "..."
|
||||
}
|
||||
return preview
|
||||
}
|
||||
404
internal/cmd/docs_paragraphs_test.go
Normal file
404
internal/cmd/docs_paragraphs_test.go
Normal file
@ -0,0 +1,404 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
)
|
||||
|
||||
// testDoc returns a realistic Google Doc with multiple paragraph types.
|
||||
func testDoc() *docs.Document {
|
||||
return &docs.Document{
|
||||
DocumentId: "test-doc-1",
|
||||
RevisionId: "rev-abc",
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
SectionBreak: &docs.SectionBreak{},
|
||||
StartIndex: 0,
|
||||
EndIndex: 0,
|
||||
},
|
||||
{
|
||||
StartIndex: 0,
|
||||
EndIndex: 27,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "TITLE"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Meeting Notes 2026-02-23\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 27,
|
||||
EndIndex: 38,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Attendees\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 38,
|
||||
EndIndex: 57,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Alice, Bob, Carol\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 57,
|
||||
EndIndex: 68,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Discussion\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 68,
|
||||
EndIndex: 94,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Bullet: &docs.Bullet{NestingLevel: 0},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Very fun! Delightful to use\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 94,
|
||||
EndIndex: 115,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Bullet: &docs.Bullet{NestingLevel: 0},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Dev sandbox is cool\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_Basic(t *testing.T) {
|
||||
doc := testDoc()
|
||||
pm, err := buildParagraphMap(doc, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildParagraphMap: %v", err)
|
||||
}
|
||||
|
||||
if pm.DocumentID != "test-doc-1" {
|
||||
t.Fatalf("unexpected documentId: %s", pm.DocumentID)
|
||||
}
|
||||
if pm.RevisionID != "rev-abc" {
|
||||
t.Fatalf("unexpected revisionId: %s", pm.RevisionID)
|
||||
}
|
||||
|
||||
// Should have 6 paragraphs (section break skipped).
|
||||
if len(pm.Paragraphs) != 6 {
|
||||
t.Fatalf("expected 6 paragraphs, got %d", len(pm.Paragraphs))
|
||||
}
|
||||
|
||||
// Check first paragraph (title).
|
||||
p1 := pm.Paragraphs[0]
|
||||
if p1.Num != 1 {
|
||||
t.Errorf("p1.Num = %d, want 1", p1.Num)
|
||||
}
|
||||
if p1.Type != "TITLE" {
|
||||
t.Errorf("p1.Type = %q, want TITLE", p1.Type)
|
||||
}
|
||||
if p1.Text != "Meeting Notes 2026-02-23" {
|
||||
t.Errorf("p1.Text = %q, want 'Meeting Notes 2026-02-23'", p1.Text)
|
||||
}
|
||||
if p1.IsBullet {
|
||||
t.Error("p1 should not be a bullet")
|
||||
}
|
||||
if p1.ElemType != "paragraph" {
|
||||
t.Errorf("p1.ElemType = %q, want paragraph", p1.ElemType)
|
||||
}
|
||||
|
||||
// Check heading.
|
||||
p2 := pm.Paragraphs[1]
|
||||
if p2.Type != "HEADING_1" {
|
||||
t.Errorf("p2.Type = %q, want HEADING_1", p2.Type)
|
||||
}
|
||||
|
||||
// Check bullet paragraph.
|
||||
p5 := pm.Paragraphs[4]
|
||||
if !p5.IsBullet {
|
||||
t.Error("p5 should be a bullet")
|
||||
}
|
||||
if p5.NestLevel != 0 {
|
||||
t.Errorf("p5.NestLevel = %d, want 0", p5.NestLevel)
|
||||
}
|
||||
if p5.Text != "Very fun! Delightful to use" {
|
||||
t.Errorf("p5.Text = %q", p5.Text)
|
||||
}
|
||||
|
||||
// Check indices.
|
||||
if p1.StartIndex != 0 || p1.EndIndex != 27 {
|
||||
t.Errorf("p1 indices: start=%d end=%d, want 0-27", p1.StartIndex, p1.EndIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_WithTable(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
DocumentId: "doc-table",
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
SectionBreak: &docs.SectionBreak{},
|
||||
StartIndex: 0,
|
||||
EndIndex: 0,
|
||||
},
|
||||
{
|
||||
StartIndex: 0,
|
||||
EndIndex: 7,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Hello\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 7,
|
||||
EndIndex: 30,
|
||||
Table: &docs.Table{
|
||||
TableRows: []*docs.TableRow{
|
||||
{
|
||||
TableCells: []*docs.TableCell{
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "A"}}}}}}},
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "B"}}}}}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
TableCells: []*docs.TableCell{
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "C"}}}}}}},
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "D"}}}}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm, err := buildParagraphMap(doc, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildParagraphMap: %v", err)
|
||||
}
|
||||
|
||||
if len(pm.Paragraphs) != 2 {
|
||||
t.Fatalf("expected 2 paragraphs, got %d", len(pm.Paragraphs))
|
||||
}
|
||||
|
||||
table := pm.Paragraphs[1]
|
||||
if table.ElemType != "table" {
|
||||
t.Fatalf("expected table, got %s", table.ElemType)
|
||||
}
|
||||
if table.TableRows != 2 || table.TableCols != 2 {
|
||||
t.Fatalf("table dimensions: %dx%d, want 2x2", table.TableRows, table.TableCols)
|
||||
}
|
||||
if table.Type != "TABLE" {
|
||||
t.Errorf("table.Type = %q, want TABLE", table.Type)
|
||||
}
|
||||
if table.Text != "A | B" {
|
||||
t.Errorf("table preview = %q, want 'A | B'", table.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_NilDoc(t *testing.T) {
|
||||
_, err := buildParagraphMap(nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_NoBody(t *testing.T) {
|
||||
doc := &docs.Document{DocumentId: "no-body"}
|
||||
_, err := buildParagraphMap(doc, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for doc with no body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_DefaultStyleType(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
DocumentId: "doc-no-style",
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
StartIndex: 0,
|
||||
EndIndex: 6,
|
||||
Paragraph: &docs.Paragraph{
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Hello\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm, err := buildParagraphMap(doc, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildParagraphMap: %v", err)
|
||||
}
|
||||
|
||||
if len(pm.Paragraphs) != 1 {
|
||||
t.Fatalf("expected 1 paragraph, got %d", len(pm.Paragraphs))
|
||||
}
|
||||
if pm.Paragraphs[0].Type != "NORMAL_TEXT" {
|
||||
t.Errorf("expected NORMAL_TEXT default, got %q", pm.Paragraphs[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParagraphMap_Get(t *testing.T) {
|
||||
pm := ¶graphMap{
|
||||
Paragraphs: []docParagraph{
|
||||
{Num: 1, Text: "first"},
|
||||
{Num: 2, Text: "second"},
|
||||
},
|
||||
}
|
||||
|
||||
p, err := pm.get(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get(1): %v", err)
|
||||
}
|
||||
if p.Text != "first" {
|
||||
t.Fatalf("get(1).Text = %q, want first", p.Text)
|
||||
}
|
||||
|
||||
_, err = pm.get(0)
|
||||
if err == nil {
|
||||
t.Fatal("get(0) should fail")
|
||||
}
|
||||
|
||||
_, err = pm.get(3)
|
||||
if err == nil {
|
||||
t.Fatal("get(3) should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_WithNestedBullets(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
DocumentId: "doc-nested",
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
StartIndex: 0,
|
||||
EndIndex: 8,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Bullet: &docs.Bullet{NestingLevel: 0},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Top\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
StartIndex: 8,
|
||||
EndIndex: 18,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Bullet: &docs.Bullet{NestingLevel: 1},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Nested\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm, err := buildParagraphMap(doc, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildParagraphMap: %v", err)
|
||||
}
|
||||
|
||||
if len(pm.Paragraphs) != 2 {
|
||||
t.Fatalf("expected 2 paragraphs, got %d", len(pm.Paragraphs))
|
||||
}
|
||||
|
||||
if pm.Paragraphs[0].NestLevel != 0 {
|
||||
t.Errorf("p1 nest level = %d, want 0", pm.Paragraphs[0].NestLevel)
|
||||
}
|
||||
if pm.Paragraphs[1].NestLevel != 1 {
|
||||
t.Errorf("p2 nest level = %d, want 1", pm.Paragraphs[1].NestLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_WithTab(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
DocumentId: "doc-tabs",
|
||||
RevisionId: "rev-tab",
|
||||
Tabs: []*docs.Tab{
|
||||
{
|
||||
TabProperties: &docs.TabProperties{
|
||||
TabId: "t.0",
|
||||
Title: "Main",
|
||||
},
|
||||
DocumentTab: &docs.DocumentTab{
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
StartIndex: 0,
|
||||
EndIndex: 10,
|
||||
Paragraph: &docs.Paragraph{
|
||||
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
|
||||
Elements: []*docs.ParagraphElement{
|
||||
{TextRun: &docs.TextRun{Content: "Tab text\n"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm, err := buildParagraphMap(doc, "Main")
|
||||
if err != nil {
|
||||
t.Fatalf("buildParagraphMap with tab: %v", err)
|
||||
}
|
||||
|
||||
if pm.TabID != "t.0" {
|
||||
t.Errorf("tabID = %q, want t.0", pm.TabID)
|
||||
}
|
||||
if len(pm.Paragraphs) != 1 {
|
||||
t.Fatalf("expected 1 paragraph, got %d", len(pm.Paragraphs))
|
||||
}
|
||||
if pm.Paragraphs[0].Text != "Tab text" {
|
||||
t.Errorf("text = %q, want 'Tab text'", pm.Paragraphs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParagraphMap_TabNotFound(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
DocumentId: "doc-tabs",
|
||||
Tabs: []*docs.Tab{
|
||||
{
|
||||
TabProperties: &docs.TabProperties{
|
||||
TabId: "t.0",
|
||||
Title: "Main",
|
||||
},
|
||||
DocumentTab: &docs.DocumentTab{
|
||||
Body: &docs.Body{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := buildParagraphMap(doc, "Nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected tab not found error")
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ type DocsSedCmd struct {
|
||||
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)"`
|
||||
Tab string `name:"tab" help:"Tab title or ID for paragraph addressing"`
|
||||
}
|
||||
|
||||
// parseExpressionLines splits data into trimmed non-empty, non-comment lines.
|
||||
@ -75,6 +76,14 @@ func (c *DocsSedCmd) collectExpressions() ([]string, error) {
|
||||
return exprs, nil
|
||||
}
|
||||
|
||||
// sedAddress represents a paragraph-number address prefix on a sed expression.
|
||||
// Addresses target specific paragraphs by number (1-based), or $ for last.
|
||||
type sedAddress struct {
|
||||
Start int // 1-based paragraph number, -1 = last ($)
|
||||
End int // 0 = same as Start (single paragraph), -1 = last ($)
|
||||
HasRange bool // true if comma-separated range was given
|
||||
}
|
||||
|
||||
type sedExpr struct {
|
||||
pattern string
|
||||
replacement string // escaped for Go's regexp.ReplaceAllString ($$ = literal $, ${N} = backref)
|
||||
@ -85,6 +94,7 @@ type sedExpr struct {
|
||||
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
|
||||
addr *sedAddress // optional paragraph address prefix (e.g., 5, 3,7, $)
|
||||
}
|
||||
|
||||
type indexedExpr struct {
|
||||
@ -252,6 +262,23 @@ func (c *DocsSedCmd) runPositionalInsert(ctx context.Context, u *ui.UI, account,
|
||||
// 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 addressed expressions (paragraph-number targeting)
|
||||
if expr.addr != nil {
|
||||
switch expr.command {
|
||||
case 'd':
|
||||
return c.runAddressedDelete(ctx, u, account, id, c.Tab, expr)
|
||||
case 'a':
|
||||
return c.runAddressedAppend(ctx, u, account, id, c.Tab, expr)
|
||||
case 'i':
|
||||
return c.runAddressedInsert(ctx, u, account, id, c.Tab, expr)
|
||||
case 0:
|
||||
// s// substitution scoped to addressed paragraphs
|
||||
return c.runAddressedSubstitute(ctx, u, account, id, c.Tab, expr)
|
||||
default:
|
||||
return fmt.Errorf("addressed %c command not supported", expr.command)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-substitution commands
|
||||
switch expr.command {
|
||||
case 'd':
|
||||
@ -327,9 +354,13 @@ func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string,
|
||||
// reliably fetch images when mixed with other batch operations.
|
||||
var imageExprs []indexedExpr
|
||||
|
||||
var addressedExprs []indexedExpr
|
||||
|
||||
for i, expr := range exprs {
|
||||
ie := indexedExpr{i, expr}
|
||||
switch classifyExprForBatch(expr) {
|
||||
case exprCatAddressed:
|
||||
addressedExprs = append(addressedExprs, ie)
|
||||
case exprCatPositional:
|
||||
positionalExprs = append(positionalExprs, ie)
|
||||
case exprCatImage:
|
||||
@ -365,6 +396,14 @@ func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string,
|
||||
}
|
||||
}
|
||||
|
||||
// Run addressed expressions sequentially (each changes doc state via paragraph map)
|
||||
for _, ie := range addressedExprs {
|
||||
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
|
||||
return fmt.Errorf("expression %d: %w", ie.index+1, singleErr)
|
||||
}
|
||||
totalReplaced++
|
||||
}
|
||||
|
||||
// Batch all native expressions into one API call
|
||||
if len(nativeExprs) > 0 {
|
||||
var requests []*docs.Request
|
||||
@ -484,10 +523,15 @@ const (
|
||||
exprCatImagePattern // image pattern in search (!(n), ![re])
|
||||
exprCatNative // plain text replace via native API
|
||||
exprCatManual // requires manual formatting path
|
||||
exprCatAddressed // paragraph-addressed — sequential, changes doc state
|
||||
)
|
||||
|
||||
// classifyExprForBatch determines how an expression should be processed in batch mode.
|
||||
func classifyExprForBatch(expr sedExpr) exprCategory {
|
||||
// Addressed expressions must run sequentially — they change document state
|
||||
if expr.addr != nil {
|
||||
return exprCatAddressed
|
||||
}
|
||||
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
|
||||
(expr.pattern == "^$" || expr.pattern == "^" || expr.pattern == "$") {
|
||||
return exprCatPositional
|
||||
|
||||
@ -220,3 +220,269 @@ func extractParagraphText(p *docs.Paragraph) string {
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// --- Addressed command implementations ---
|
||||
|
||||
// resolveAddress converts a sedAddress into a slice of target paragraphs from the map.
|
||||
func resolveAddress(addr *sedAddress, pm *paragraphMap) ([]docParagraph, error) {
|
||||
if addr == nil {
|
||||
return nil, fmt.Errorf("nil address")
|
||||
}
|
||||
if len(pm.Paragraphs) == 0 {
|
||||
return nil, fmt.Errorf("document has no paragraphs")
|
||||
}
|
||||
|
||||
last := len(pm.Paragraphs)
|
||||
|
||||
start := addr.Start
|
||||
if start == -1 {
|
||||
start = last
|
||||
}
|
||||
if start < 1 || start > last {
|
||||
return nil, fmt.Errorf("address %d out of range (document has %d paragraphs)", start, last)
|
||||
}
|
||||
|
||||
if !addr.HasRange {
|
||||
return []docParagraph{pm.Paragraphs[start-1]}, nil
|
||||
}
|
||||
|
||||
end := addr.End
|
||||
if end == 0 {
|
||||
end = start
|
||||
}
|
||||
if end == -1 {
|
||||
end = last
|
||||
}
|
||||
if end < 1 || end > last {
|
||||
return nil, fmt.Errorf("address end %d out of range (document has %d paragraphs)", end, last)
|
||||
}
|
||||
|
||||
return pm.Paragraphs[start-1 : end], nil
|
||||
}
|
||||
|
||||
// runAddressedDelete deletes paragraphs by address (number or range).
|
||||
func (c *DocsSedCmd) runAddressedDelete(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := resolveAddress(expr.addr, pm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build delete requests in reverse order to preserve indices
|
||||
var requests []*docs.Request
|
||||
for i := len(targets) - 1; i >= 0; i-- {
|
||||
para := targets[i]
|
||||
startIndex := para.StartIndex
|
||||
endIndex := para.EndIndex
|
||||
|
||||
isLast := para.Num == len(pm.Paragraphs)
|
||||
if isLast && para.Num > 1 {
|
||||
// Last paragraph: delete from end of previous paragraph to our end-1
|
||||
prev := pm.Paragraphs[para.Num-2]
|
||||
startIndex = prev.EndIndex - 1
|
||||
endIndex = para.EndIndex - 1
|
||||
} else if isLast && para.Num == 1 {
|
||||
// Only paragraph: just clear the text
|
||||
if para.StartIndex >= para.EndIndex-1 {
|
||||
continue // empty paragraph, skip
|
||||
}
|
||||
endIndex = para.EndIndex - 1
|
||||
}
|
||||
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: startIndex,
|
||||
EndIndex: endIndex,
|
||||
TabId: pm.TabID,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0"})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (addressed delete): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
|
||||
}
|
||||
|
||||
// runAddressedAppend inserts text after the addressed paragraph(s).
|
||||
func (c *DocsSedCmd) runAddressedAppend(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := resolveAddress(expr.addr, pm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
|
||||
if !strings.HasSuffix(insertText, "\n") {
|
||||
insertText = "\n" + insertText
|
||||
} else {
|
||||
insertText = "\n" + insertText[:len(insertText)-1]
|
||||
}
|
||||
|
||||
// Insert in reverse order to preserve indices
|
||||
var requests []*docs.Request
|
||||
for i := len(targets) - 1; i >= 0; i-- {
|
||||
para := targets[i]
|
||||
// Insert before the trailing \n of the paragraph
|
||||
idx := para.EndIndex - 1
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: idx, TabId: pm.TabID},
|
||||
Text: insertText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (addressed append): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "appended", Value: fmt.Sprintf("%d paragraphs", len(targets))})
|
||||
}
|
||||
|
||||
// runAddressedInsert inserts text before the addressed paragraph(s).
|
||||
func (c *DocsSedCmd) runAddressedInsert(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := resolveAddress(expr.addr, pm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
|
||||
if !strings.HasSuffix(insertText, "\n") {
|
||||
insertText += "\n"
|
||||
}
|
||||
|
||||
// Insert in reverse order to preserve indices
|
||||
var requests []*docs.Request
|
||||
for i := len(targets) - 1; i >= 0; i-- {
|
||||
para := targets[i]
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: para.StartIndex, TabId: pm.TabID},
|
||||
Text: insertText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (addressed insert): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "inserted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
|
||||
}
|
||||
|
||||
// runAddressedSubstitute applies a substitution only within the addressed paragraph(s).
|
||||
func (c *DocsSedCmd) runAddressedSubstitute(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
|
||||
docsSvc, err := newDocsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := resolveAddress(expr.addr, pm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
re, compileErr := expr.compilePattern()
|
||||
if compileErr != nil {
|
||||
return fmt.Errorf("compile pattern: %w", compileErr)
|
||||
}
|
||||
|
||||
// For each target paragraph, find matches and apply substitutions.
|
||||
// Work in reverse order to preserve indices.
|
||||
var requests []*docs.Request
|
||||
replaced := 0
|
||||
|
||||
for i := len(targets) - 1; i >= 0; i-- {
|
||||
para := targets[i]
|
||||
text := para.Text
|
||||
|
||||
matches := re.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !expr.global {
|
||||
matches = matches[:1]
|
||||
}
|
||||
|
||||
// Process matches in reverse order within this paragraph
|
||||
for j := len(matches) - 1; j >= 0; j-- {
|
||||
m := matches[j]
|
||||
matchText := text[m[0]:m[1]]
|
||||
replText := re.ReplaceAllString(matchText, expr.replacement)
|
||||
// Unescape Go regex $$ to literal $
|
||||
replText = literalReplacement(replText)
|
||||
|
||||
absStart := para.StartIndex + int64(m[0])
|
||||
absEnd := para.StartIndex + int64(m[1])
|
||||
|
||||
requests = append(requests, &docs.Request{
|
||||
DeleteContentRange: &docs.DeleteContentRangeRequest{
|
||||
Range: &docs.Range{
|
||||
StartIndex: absStart,
|
||||
EndIndex: absEnd,
|
||||
TabId: pm.TabID,
|
||||
},
|
||||
},
|
||||
})
|
||||
requests = append(requests, &docs.Request{
|
||||
InsertText: &docs.InsertTextRequest{
|
||||
Location: &docs.Location{Index: absStart, TabId: pm.TabID},
|
||||
Text: replText,
|
||||
},
|
||||
})
|
||||
replaced++
|
||||
}
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: 0})
|
||||
}
|
||||
|
||||
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
|
||||
return fmt.Errorf("batch update (addressed substitute): %w", err)
|
||||
}
|
||||
|
||||
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: replaced})
|
||||
}
|
||||
|
||||
@ -514,3 +514,139 @@ func TestParseHexColor(t *testing.T) {
|
||||
assert.InDelta(t, 1.0, g, 0.01)
|
||||
assert.InDelta(t, 0.0, b, 0.01)
|
||||
}
|
||||
|
||||
// --- Tests for paragraph addressing ---
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantAddr *sedAddress
|
||||
wantRest string
|
||||
wantErr bool
|
||||
}{
|
||||
{"single number", "5d", &sedAddress{Start: 5}, "d", false},
|
||||
{"single dollar", "$d", &sedAddress{Start: -1}, "d", false},
|
||||
{"range", "3,7d", &sedAddress{Start: 3, End: 7, HasRange: true}, "d", false},
|
||||
{"range with dollar", "3,$d", &sedAddress{Start: 3, End: -1, HasRange: true}, "d", false},
|
||||
{"no address s-cmd", "s/foo/bar/", nil, "s/foo/bar/", false},
|
||||
{"no address d-cmd", "d/foo/", nil, "d/foo/", false},
|
||||
{"bare number", "5", &sedAddress{Start: 5}, "", false},
|
||||
{"address with s-cmd", "5s/foo/bar/", &sedAddress{Start: 5}, "s/foo/bar/", false},
|
||||
{"address with a-cmd", "5a/text/", &sedAddress{Start: 5}, "a/text/", false},
|
||||
{"dollar with s-cmd", "$s/.*/new/", &sedAddress{Start: -1}, "s/.*/new/", false},
|
||||
{"range end < start", "7,3d", nil, "", true},
|
||||
{"range missing end", "3,", nil, "", true},
|
||||
{"empty", "", nil, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, rest, err := parseAddress(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if tt.wantAddr == nil {
|
||||
assert.Nil(t, addr)
|
||||
} else {
|
||||
require.NotNil(t, addr)
|
||||
assert.Equal(t, tt.wantAddr.Start, addr.Start)
|
||||
assert.Equal(t, tt.wantAddr.End, addr.End)
|
||||
assert.Equal(t, tt.wantAddr.HasRange, addr.HasRange)
|
||||
}
|
||||
assert.Equal(t, tt.wantRest, rest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFullExpr_Addressed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantAddr *sedAddress
|
||||
wantCmd byte
|
||||
wantPat string
|
||||
wantRepl string
|
||||
wantErr bool
|
||||
}{
|
||||
{"bare delete", "5d", &sedAddress{Start: 5}, 'd', "", "", false},
|
||||
{"range delete", "3,7d", &sedAddress{Start: 3, End: 7, HasRange: true}, 'd', "", "", false},
|
||||
{"dollar delete", "$d", &sedAddress{Start: -1}, 'd', "", "", false},
|
||||
{"addressed s-cmd", "5s/foo/bar/", &sedAddress{Start: 5}, 0, "foo", "bar", false},
|
||||
{"range s-cmd", "3,7s/old/new/g", &sedAddress{Start: 3, End: 7, HasRange: true}, 0, "old", "new", false},
|
||||
{"addressed append", "5a/new text/", &sedAddress{Start: 5}, 'a', "", "new text", false},
|
||||
{"addressed insert", "3i/before text/", &sedAddress{Start: 3}, 'i', "", "before text", false},
|
||||
{"dollar append", "$a/last line/", &sedAddress{Start: -1}, 'a', "", "last line", false},
|
||||
{"addressed d-with-pattern", "5d/foo/", &sedAddress{Start: 5}, 'd', "foo", "", false},
|
||||
{"bare number error", "5", nil, 0, "", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
expr, err := parseFullExpr(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if tt.wantAddr == nil {
|
||||
assert.Nil(t, expr.addr)
|
||||
} else {
|
||||
require.NotNil(t, expr.addr)
|
||||
assert.Equal(t, tt.wantAddr.Start, expr.addr.Start)
|
||||
assert.Equal(t, tt.wantAddr.End, expr.addr.End)
|
||||
assert.Equal(t, tt.wantAddr.HasRange, expr.addr.HasRange)
|
||||
}
|
||||
assert.Equal(t, tt.wantCmd, expr.command)
|
||||
if tt.wantPat != "" {
|
||||
assert.Equal(t, tt.wantPat, expr.pattern)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAddress(t *testing.T) {
|
||||
pm := ¶graphMap{
|
||||
Paragraphs: []docParagraph{
|
||||
{Num: 1, Text: "first", StartIndex: 0, EndIndex: 6},
|
||||
{Num: 2, Text: "second", StartIndex: 6, EndIndex: 13},
|
||||
{Num: 3, Text: "third", StartIndex: 13, EndIndex: 19},
|
||||
{Num: 4, Text: "fourth", StartIndex: 19, EndIndex: 26},
|
||||
{Num: 5, Text: "fifth", StartIndex: 26, EndIndex: 32},
|
||||
},
|
||||
}
|
||||
|
||||
// Single address
|
||||
targets, err := resolveAddress(&sedAddress{Start: 3}, pm)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, targets, 1)
|
||||
assert.Equal(t, "third", targets[0].Text)
|
||||
|
||||
// Last paragraph ($)
|
||||
targets, err = resolveAddress(&sedAddress{Start: -1}, pm)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, targets, 1)
|
||||
assert.Equal(t, "fifth", targets[0].Text)
|
||||
|
||||
// Range
|
||||
targets, err = resolveAddress(&sedAddress{Start: 2, End: 4, HasRange: true}, pm)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, targets, 3)
|
||||
assert.Equal(t, "second", targets[0].Text)
|
||||
assert.Equal(t, "fourth", targets[2].Text)
|
||||
|
||||
// Range ending with $
|
||||
targets, err = resolveAddress(&sedAddress{Start: 3, End: -1, HasRange: true}, pm)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, targets, 3)
|
||||
assert.Equal(t, "third", targets[0].Text)
|
||||
assert.Equal(t, "fifth", targets[2].Text)
|
||||
|
||||
// Out of range
|
||||
_, err = resolveAddress(&sedAddress{Start: 10}, pm)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Empty paragraph map
|
||||
_, err = resolveAddress(&sedAddress{Start: 1}, ¶graphMap{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@ -152,7 +152,7 @@ func TestSedIntegration_MarkdownBulletToPlain(t *testing.T) {
|
||||
|
||||
func TestSedIntegration_LongDocument(t *testing.T) {
|
||||
// Build a document with many paragraphs
|
||||
paras := make([]docParagraph, 50)
|
||||
paras := make([]testDocParagraph, 50)
|
||||
for i := range paras {
|
||||
if i%5 == 0 {
|
||||
paras[i] = para(plain("Replace this target word here"))
|
||||
|
||||
@ -62,7 +62,7 @@ func mockDocsServerAdvanced(t *testing.T, doc *docs.Document, onBatchUpdate func
|
||||
}
|
||||
|
||||
// buildDoc constructs a realistic multi-paragraph Google Doc for testing.
|
||||
func buildDoc(paragraphs ...docParagraph) *docs.Document {
|
||||
func buildDoc(paragraphs ...testDocParagraph) *docs.Document {
|
||||
content := make([]*docs.StructuralElement, 0, len(paragraphs))
|
||||
idx := int64(1) // Google Docs indices start at 1 (0 is reserved)
|
||||
|
||||
@ -190,7 +190,7 @@ type textRun struct {
|
||||
style *docs.TextStyle
|
||||
}
|
||||
|
||||
type docParagraph struct {
|
||||
type testDocParagraph struct {
|
||||
runs []textRun
|
||||
}
|
||||
|
||||
@ -202,8 +202,8 @@ func bold(text string) textRun {
|
||||
return textRun{text: text, style: &docs.TextStyle{Bold: true}}
|
||||
}
|
||||
|
||||
func para(runs ...textRun) docParagraph {
|
||||
return docParagraph{runs: runs}
|
||||
func para(runs ...textRun) testDocParagraph {
|
||||
return testDocParagraph{runs: runs}
|
||||
}
|
||||
|
||||
// runSedIntegration runs a DocsSedCmd against a mock server and returns captured requests.
|
||||
|
||||
@ -284,13 +284,167 @@ func parseMarkdownReplacement(repl string) (text string, formats []string) {
|
||||
return text, formats
|
||||
}
|
||||
|
||||
// parseAddress strips an optional paragraph-number address prefix from a raw expression.
|
||||
// Addresses are: N (single), N,M (range), $ (last), $-N (offset from last).
|
||||
// Returns nil address and the original string if no address prefix found.
|
||||
func parseAddress(raw string) (*sedAddress, string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
// Check for $ (last paragraph) prefix
|
||||
if raw[0] == '$' {
|
||||
if len(raw) == 1 {
|
||||
return &sedAddress{Start: -1}, "", nil
|
||||
}
|
||||
remaining := raw[1:]
|
||||
// $,N range
|
||||
if remaining[0] == ',' {
|
||||
return nil, raw, fmt.Errorf("invalid address: $ cannot be range start (use N,$ instead)")
|
||||
}
|
||||
// $ followed by a command
|
||||
return &sedAddress{Start: -1}, remaining, nil
|
||||
}
|
||||
|
||||
// Check for leading digits
|
||||
if raw[0] < '0' || raw[0] > '9' {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
// Parse the start number
|
||||
i := 0
|
||||
for i < len(raw) && raw[i] >= '0' && raw[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
start, err := strconv.Atoi(raw[:i])
|
||||
if err != nil || start < 1 {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
remaining := raw[i:]
|
||||
|
||||
// Check for comma (range)
|
||||
if len(remaining) > 0 && remaining[0] == ',' {
|
||||
remaining = remaining[1:]
|
||||
if len(remaining) == 0 {
|
||||
return nil, raw, fmt.Errorf("invalid address: range missing end")
|
||||
}
|
||||
// End can be $ or a number
|
||||
if remaining[0] == '$' {
|
||||
return &sedAddress{Start: start, End: -1, HasRange: true}, remaining[1:], nil
|
||||
}
|
||||
j := 0
|
||||
for j < len(remaining) && remaining[j] >= '0' && remaining[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
if j == 0 {
|
||||
return nil, raw, fmt.Errorf("invalid address: range end must be a number or $")
|
||||
}
|
||||
end, endErr := strconv.Atoi(remaining[:j])
|
||||
if endErr != nil || end < 1 {
|
||||
return nil, raw, fmt.Errorf("invalid address: range end must be >= 1")
|
||||
}
|
||||
if end < start {
|
||||
return nil, raw, fmt.Errorf("invalid address: range end (%d) < start (%d)", end, start)
|
||||
}
|
||||
return &sedAddress{Start: start, End: end, HasRange: true}, remaining[j:], nil
|
||||
}
|
||||
|
||||
// Single address — but only if followed by a command character, not more digits
|
||||
// that could be part of something else (like a pattern).
|
||||
// We need to distinguish "5d" (address 5 + delete) from "5" by itself.
|
||||
if len(remaining) == 0 {
|
||||
// Bare number with nothing after — treat as addressed bare command (needs a command)
|
||||
return &sedAddress{Start: start}, "", nil
|
||||
}
|
||||
|
||||
return &sedAddress{Start: start}, remaining, nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Supports optional paragraph-number address prefix: 5d, 3,7s/foo/bar/, $a/text/.
|
||||
func parseFullExpr(raw string) (sedExpr, error) {
|
||||
if len(raw) == 0 {
|
||||
return sedExpr{}, fmt.Errorf("empty expression")
|
||||
}
|
||||
|
||||
// Try to parse an address prefix
|
||||
addr, remaining, addrErr := parseAddress(raw)
|
||||
if addrErr != nil {
|
||||
return sedExpr{}, addrErr
|
||||
}
|
||||
|
||||
// If we got an address, parse the remaining expression
|
||||
if addr != nil && remaining != "" {
|
||||
// Remaining starts with a command character
|
||||
var expr sedExpr
|
||||
var err error
|
||||
|
||||
// Check for non-substitution commands
|
||||
if len(remaining) >= 1 {
|
||||
switch remaining[0] {
|
||||
case 'd':
|
||||
if len(remaining) == 1 {
|
||||
// Bare addressed delete: "5d" or "3,7d"
|
||||
expr = sedExpr{command: 'd'}
|
||||
expr.addr = addr
|
||||
return expr, nil
|
||||
}
|
||||
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
|
||||
expr, err = parseDCommand(remaining)
|
||||
if err != nil {
|
||||
return sedExpr{}, err
|
||||
}
|
||||
expr.addr = addr
|
||||
return expr, nil
|
||||
}
|
||||
case 'a':
|
||||
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
|
||||
expr, err = parseAddressedAICommand(remaining, 'a')
|
||||
if err != nil {
|
||||
return sedExpr{}, err
|
||||
}
|
||||
expr.addr = addr
|
||||
return expr, nil
|
||||
}
|
||||
case 'i':
|
||||
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
|
||||
expr, err = parseAddressedAICommand(remaining, 'i')
|
||||
if err != nil {
|
||||
return sedExpr{}, err
|
||||
}
|
||||
expr.addr = addr
|
||||
return expr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise parse as s// or other standard command
|
||||
expr, err = parseFullExprInner(remaining)
|
||||
if err != nil {
|
||||
return sedExpr{}, err
|
||||
}
|
||||
expr.addr = addr
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// Address with no remaining command — bare addressed command
|
||||
if addr != nil && remaining == "" {
|
||||
return sedExpr{}, fmt.Errorf("address without command: %q", raw)
|
||||
}
|
||||
|
||||
// No address — parse normally
|
||||
return parseFullExprInner(raw)
|
||||
}
|
||||
|
||||
// parseFullExprInner is the original parseFullExpr logic, extracted so parseFullExpr
|
||||
// can handle address prefixes before delegating.
|
||||
func parseFullExprInner(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]) {
|
||||
@ -457,6 +611,20 @@ func parseDCommand(raw string) (sedExpr, error) {
|
||||
return sedExpr{pattern: pattern, command: 'd'}, nil
|
||||
}
|
||||
|
||||
// parseAddressedAICommand parses append/insert when used with a paragraph address: Na/text/.
|
||||
// Unlike pattern-matched a/i, addressed a/i takes a single field (the text to insert),
|
||||
// since the address already specifies where.
|
||||
func parseAddressedAICommand(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) < 1 || parts[0] == "" {
|
||||
return sedExpr{}, fmt.Errorf("invalid addressed %c command (expected %c/text/)", cmd, cmd)
|
||||
}
|
||||
return sedExpr{replacement: parts[0], command: cmd}, 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user