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
149 changes: 147 additions & 2 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,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 in document"`
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 Expand Up @@ -692,6 +693,150 @@ func (c *DocsListTabsCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}

// --- Read command (character-based pagination) ---

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 {
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
}

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))
}

// --- Write / Insert / Delete / Find-Replace commands ---

type DocsInsertCmd struct {
Expand Down
102 changes: 68 additions & 34 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 All @@ -12,12 +13,13 @@ import (
"github.com/steipete/gogcli/internal/ui"
)

// DocsEditCmd does find/replace in a Google Doc
// DocsEditCmd replaces, inserts, or deletes text 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"`
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 {
Expand All @@ -27,58 +29,90 @@ func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

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

if c.Find == "" {
return usage("empty find text")
if oldText == "" {
return usage("--old cannot be empty")
}

// Create Docs service
docsSvc, err := newDocsService(ctx, account)
svc, err := newDocsService(ctx, account)
if err != nil {
return fmt.Errorf("create docs service: %w", err)
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)
}

// Build replace request
requests := []*docs.Request{
{
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: c.Find,
Text: oldText,
MatchCase: c.MatchCase,
},
ReplaceText: c.ReplaceStr,
ReplaceText: newText,
},
},
}

// 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)
return fmt.Errorf("edit 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
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{
"status": "ok",
"docId": id,
"replaced": replaced,
"documentId": result.DocumentId,
"old": oldText,
"new": newText,
"replacements": replacements,
"matchCase": c.MatchCase,
})
}

u.Out().Printf("status\tok")
u.Out().Printf("docId\t%s", id)
u.Out().Printf("replaced\t%d", replaced)
u.Out().Printf("replaced %d occurrence%s", replacements, plural(int(replacements)))
return nil
}
12 changes: 6 additions & 6 deletions internal/cmd/docs_edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,9 +439,9 @@ func TestDocsEditCmd_EmptyDocId(t *testing.T) {
ctx := ui.WithUI(context.Background(), u)

cmd := &DocsEditCmd{
DocID: " ",
Find: "foo",
ReplaceStr: "bar",
DocID: " ",
Old: "foo",
New: "bar",
}

flags := &RootFlags{Account: "test@example.com"}
Expand All @@ -459,9 +459,9 @@ func TestDocsEditCmd_EmptyFind(t *testing.T) {
ctx := ui.WithUI(context.Background(), u)

cmd := &DocsEditCmd{
DocID: "doc123",
Find: "",
ReplaceStr: "bar",
DocID: "doc123",
Old: "",
New: "bar",
}

flags := &RootFlags{Account: "test@example.com"}
Expand Down
Loading