Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ type DocsCmd struct {
Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"`
Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"`
Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"`
Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"`
Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text" help:"Print a Google Doc as plain text"`
Read DocsReadCmd `cmd:"" name:"read" help:"Read document content with character offset/limit for pagination. Use --offset and --limit to page through large documents."`
Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"`
ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"`
Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"`
Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"`
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence."`
Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"`
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
Edit DocsEditCmd `cmd:"" name:"edit" help:"Replace, insert, or delete text in a Google Doc. Use --new '' to delete. Use \\n in --new to insert paragraphs. Requires unique match unless --replace-all. Use --dry-run to preview."`
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"`
Expand Down
105 changes: 95 additions & 10 deletions internal/cmd/docs_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"
"strings"
Expand Down Expand Up @@ -348,20 +349,104 @@ type DocsFindReplaceCmd struct {
TabID string `name:"tab-id" help:"Target a specific tab by ID (see docs list-tabs)"`
}

// DocsEditCmd replaces, inserts, or deletes text in a Google Doc.
type DocsEditCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Find string `arg:"" name:"find" help:"Text to find"`
ReplaceStr string `arg:"" name:"replace" help:"Replacement text"`
MatchCase bool `name:"match-case" help:"Case-sensitive matching"`
DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"`
Old string `name:"old" required:"" help:"Text to find (must be unique unless --replace-all)"`
New string `name:"new" required:"" help:"Replacement text (use '' to delete, use \\n to insert paragraphs)"`
ReplaceAll bool `name:"replace-all" help:"Replace all occurrences (required if --old matches more than once)"`
MatchCase bool `name:"match-case" help:"Case-sensitive matching (use --no-match-case for case-insensitive)" default:"true" negatable:""`
}

func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
return (&DocsFindReplaceCmd{
DocID: c.DocID,
Find: c.Find,
ReplaceText: c.ReplaceStr,
MatchCase: c.MatchCase,
}).Run(ctx, flags)
u := ui.FromContext(ctx)

docID := strings.TrimSpace(c.DocID)
if docID == "" {
return usage("empty docId")
}
oldText := unescapeString(c.Old)
newText := unescapeString(c.New)

if oldText == "" {
return usage("--old cannot be empty")
}

svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}

// Fetch document text to validate uniqueness.
doc, err := svc.Documents.Get(docID).
Context(ctx).
Do()
if err != nil {
if isDocsNotFound(err) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
}
return err
}
if doc == nil {
return errors.New("doc not found")
}

plainText := docsPlainText(doc, 0)
occurrences := countOccurrences(plainText, oldText, c.MatchCase)

if occurrences == 0 {
return fmt.Errorf("%q not found", oldText)
}
if !c.ReplaceAll && occurrences > 1 {
return fmt.Errorf("%q is not unique (found %d occurrences); use --replace-all to replace all", oldText, occurrences)
}

if flags.DryRun {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"dry_run": true,
"documentId": docID,
"old": oldText,
"new": newText,
"occurrences": occurrences,
})
}
u.Out().Printf("would replace %d occurrence%s", occurrences, plural(occurrences))
return nil
}

result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: []*docs.Request{{
ReplaceAllText: &docs.ReplaceAllTextRequest{
ContainsText: &docs.SubstringMatchCriteria{
Text: oldText,
MatchCase: c.MatchCase,
},
ReplaceText: newText,
},
}},
}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("edit document: %w", err)
}

replacements := int64(0)
if len(result.Replies) > 0 && result.Replies[0].ReplaceAllText != nil {
replacements = result.Replies[0].ReplaceAllText.OccurrencesChanged
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"documentId": result.DocumentId,
"old": oldText,
"new": newText,
"replacements": replacements,
"matchCase": c.MatchCase,
})
}

u.Out().Printf("replaced %d occurrence%s", replacements, plural(int(replacements)))
return nil
}

func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand Down
137 changes: 137 additions & 0 deletions internal/cmd/docs_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,140 @@ func tabInfoJSON(tab *docs.Tab) map[string]any {
}
return m
}

type DocsReadCmd struct {
DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"`
Tab string `name:"tab" help:"Read a specific tab by title or ID"`
Offset int `name:"offset" help:"Character offset to start from (0-indexed)" default:"0"`
Limit int `name:"limit" help:"Max characters to return (default 100000, 0 = all)" default:"100000"`
}

func (c *DocsReadCmd) Run(ctx context.Context, flags *RootFlags) error {
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}

svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}

var text string
if c.Tab != "" {
doc, docErr := svc.Documents.Get(id).
IncludeTabsContent(true).
Context(ctx).
Do()
if docErr != nil {
if isDocsNotFound(docErr) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
}
return docErr
}
if doc == nil {
return errors.New("doc not found")
}
tabs := flattenTabs(doc.Tabs)
tab := findTab(tabs, c.Tab)
if tab == nil {
return fmt.Errorf("tab not found: %s", c.Tab)
}
text = tabPlainText(tab, 0)
} else {
doc, docErr := svc.Documents.Get(id).
Context(ctx).
Do()
if docErr != nil {
if isDocsNotFound(docErr) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
}
return docErr
}
if doc == nil {
return errors.New("doc not found")
}
text = docsPlainText(doc, 0)
}

totalChars := len(text)
text = applyCharWindow(text, c.Offset, c.Limit)

if outfmt.IsJSON(ctx) {
result := map[string]any{
"text": text,
"totalChars": totalChars,
}
if c.Offset > 0 {
result["offset"] = c.Offset
}
if c.Limit > 0 {
result["limit"] = c.Limit
}
return outfmt.WriteJSON(ctx, os.Stdout, result)
}

_, err = io.WriteString(os.Stdout, text)
return err
}

// applyCharWindow returns a substring of text based on character offset and limit.
// offset is 0-indexed. limit=0 means unlimited.
func applyCharWindow(text string, offset, limit int) string {
if offset >= len(text) {
return ""
}
if offset > 0 {
text = text[offset:]
}
if limit > 0 && len(text) > limit {
text = text[:limit]
}
return text
}

// plural returns "s" if n != 1, for use in occurrence(s) messages.
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

// unescapeString interprets common escape sequences (\n, \t, \\) in s.
func unescapeString(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}

var buf strings.Builder
buf.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
buf.WriteByte('\n')
i++
case 't':
buf.WriteByte('\t')
i++
case '\\':
buf.WriteByte('\\')
i++
default:
buf.WriteByte(s[i])
}
} else {
buf.WriteByte(s[i])
}
}
return buf.String()
}

// countOccurrences counts non-overlapping occurrences of substr in text.
func countOccurrences(text, substr string, matchCase bool) int {
if matchCase {
return strings.Count(text, substr)
}
return strings.Count(strings.ToLower(text), strings.ToLower(substr))
}
Loading