Skip to content

Commit aef903e

Browse files
committed
feat(docs): add read command and align edit with ox-cli
- Add `docs read` with character-based --offset/--limit pagination - Rewrite `docs edit` to use --old/--new with uniqueness enforcement, --replace-all, --dry-run, escape sequences, and --no-match-case - Align error messages and output format with ox-cli - Fix upstream test references to renamed struct fields
1 parent 0ed8997 commit aef903e

4 files changed

Lines changed: 744 additions & 42 deletions

File tree

internal/cmd/docs.go

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ type DocsCmd struct {
2929
Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"`
3030
Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"`
3131
Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"`
32-
Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"`
32+
Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text" help:"Print a Google Doc as plain text"`
33+
Read DocsReadCmd `cmd:"" name:"read" help:"Read document content with character offset/limit for pagination. Use --offset and --limit to page through large documents."`
3334
Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"`
3435
ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"`
3536
Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"`
3637
Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"`
3738
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
3839
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"`
3940
Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"`
40-
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
41+
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."`
4142
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
4243
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
4344
Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"`
@@ -692,6 +693,150 @@ func (c *DocsListTabsCmd) Run(ctx context.Context, flags *RootFlags) error {
692693
return nil
693694
}
694695

696+
// --- Read command (character-based pagination) ---
697+
698+
type DocsReadCmd struct {
699+
DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"`
700+
Tab string `name:"tab" help:"Read a specific tab by title or ID"`
701+
Offset int `name:"offset" help:"Character offset to start from (0-indexed)" default:"0"`
702+
Limit int `name:"limit" help:"Max characters to return (default 100000, 0 = all)" default:"100000"`
703+
}
704+
705+
func (c *DocsReadCmd) Run(ctx context.Context, flags *RootFlags) error {
706+
account, err := requireAccount(flags)
707+
if err != nil {
708+
return err
709+
}
710+
711+
id := strings.TrimSpace(c.DocID)
712+
if id == "" {
713+
return usage("empty docId")
714+
}
715+
716+
svc, err := newDocsService(ctx, account)
717+
if err != nil {
718+
return err
719+
}
720+
721+
var text string
722+
if c.Tab != "" {
723+
doc, docErr := svc.Documents.Get(id).
724+
IncludeTabsContent(true).
725+
Context(ctx).
726+
Do()
727+
if docErr != nil {
728+
if isDocsNotFound(docErr) {
729+
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
730+
}
731+
return docErr
732+
}
733+
if doc == nil {
734+
return errors.New("doc not found")
735+
}
736+
tabs := flattenTabs(doc.Tabs)
737+
tab := findTab(tabs, c.Tab)
738+
if tab == nil {
739+
return fmt.Errorf("tab not found: %s", c.Tab)
740+
}
741+
text = tabPlainText(tab, 0)
742+
} else {
743+
doc, docErr := svc.Documents.Get(id).
744+
Context(ctx).
745+
Do()
746+
if docErr != nil {
747+
if isDocsNotFound(docErr) {
748+
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
749+
}
750+
return docErr
751+
}
752+
if doc == nil {
753+
return errors.New("doc not found")
754+
}
755+
text = docsPlainText(doc, 0)
756+
}
757+
758+
totalChars := len(text)
759+
text = applyCharWindow(text, c.Offset, c.Limit)
760+
761+
if outfmt.IsJSON(ctx) {
762+
result := map[string]any{
763+
"text": text,
764+
"totalChars": totalChars,
765+
}
766+
if c.Offset > 0 {
767+
result["offset"] = c.Offset
768+
}
769+
if c.Limit > 0 {
770+
result["limit"] = c.Limit
771+
}
772+
return outfmt.WriteJSON(ctx, os.Stdout, result)
773+
}
774+
775+
_, err = io.WriteString(os.Stdout, text)
776+
return err
777+
}
778+
779+
// applyCharWindow returns a substring of text based on character offset and limit.
780+
// offset is 0-indexed. limit=0 means unlimited.
781+
func applyCharWindow(text string, offset, limit int) string {
782+
if offset >= len(text) {
783+
return ""
784+
}
785+
if offset > 0 {
786+
text = text[offset:]
787+
}
788+
if limit > 0 && len(text) > limit {
789+
text = text[:limit]
790+
}
791+
return text
792+
}
793+
794+
// plural returns "s" if n != 1, for use in occurrence(s) messages.
795+
func plural(n int) string {
796+
if n == 1 {
797+
return ""
798+
}
799+
return "s"
800+
}
801+
802+
// unescapeString interprets common escape sequences (\n, \t, \\) in s.
803+
func unescapeString(s string) string {
804+
if !strings.ContainsRune(s, '\\') {
805+
return s
806+
}
807+
808+
var buf strings.Builder
809+
buf.Grow(len(s))
810+
for i := 0; i < len(s); i++ {
811+
if s[i] == '\\' && i+1 < len(s) {
812+
switch s[i+1] {
813+
case 'n':
814+
buf.WriteByte('\n')
815+
i++
816+
case 't':
817+
buf.WriteByte('\t')
818+
i++
819+
case '\\':
820+
buf.WriteByte('\\')
821+
i++
822+
default:
823+
buf.WriteByte(s[i])
824+
}
825+
} else {
826+
buf.WriteByte(s[i])
827+
}
828+
}
829+
return buf.String()
830+
}
831+
832+
// countOccurrences counts non-overlapping occurrences of substr in text.
833+
func countOccurrences(text, substr string, matchCase bool) int {
834+
if matchCase {
835+
return strings.Count(text, substr)
836+
}
837+
return strings.Count(strings.ToLower(text), strings.ToLower(substr))
838+
}
839+
695840
// --- Write / Insert / Delete / Find-Replace commands ---
696841

697842
type DocsInsertCmd struct {

internal/cmd/docs_edit.go

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"strings"
@@ -12,12 +13,13 @@ import (
1213
"github.com/steipete/gogcli/internal/ui"
1314
)
1415

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

2325
func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -27,58 +29,90 @@ func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error {
2729
return err
2830
}
2931

30-
id := strings.TrimSpace(c.DocID)
31-
if id == "" {
32+
docID := strings.TrimSpace(c.DocID)
33+
if docID == "" {
3234
return usage("empty docId")
3335
}
36+
oldText := unescapeString(c.Old)
37+
newText := unescapeString(c.New)
3438

35-
if c.Find == "" {
36-
return usage("empty find text")
39+
if oldText == "" {
40+
return usage("--old cannot be empty")
3741
}
3842

39-
// Create Docs service
40-
docsSvc, err := newDocsService(ctx, account)
43+
svc, err := newDocsService(ctx, account)
4144
if err != nil {
42-
return fmt.Errorf("create docs service: %w", err)
45+
return err
46+
}
47+
48+
// Fetch document text to validate uniqueness.
49+
doc, err := svc.Documents.Get(docID).
50+
Context(ctx).
51+
Do()
52+
if err != nil {
53+
if isDocsNotFound(err) {
54+
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
55+
}
56+
return err
57+
}
58+
if doc == nil {
59+
return errors.New("doc not found")
60+
}
61+
62+
plainText := docsPlainText(doc, 0)
63+
occurrences := countOccurrences(plainText, oldText, c.MatchCase)
64+
65+
if occurrences == 0 {
66+
return fmt.Errorf("%q not found", oldText)
67+
}
68+
if !c.ReplaceAll && occurrences > 1 {
69+
return fmt.Errorf("%q is not unique (found %d occurrences). Use --replace-all to replace all.", oldText, occurrences)
4370
}
4471

45-
// Build replace request
46-
requests := []*docs.Request{
47-
{
72+
if flags.DryRun {
73+
if outfmt.IsJSON(ctx) {
74+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
75+
"dry_run": true,
76+
"documentId": docID,
77+
"old": oldText,
78+
"new": newText,
79+
"occurrences": occurrences,
80+
})
81+
}
82+
u.Out().Printf("would replace %d occurrence%s", occurrences, plural(occurrences))
83+
return nil
84+
}
85+
86+
result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
87+
Requests: []*docs.Request{{
4888
ReplaceAllText: &docs.ReplaceAllTextRequest{
4989
ContainsText: &docs.SubstringMatchCriteria{
50-
Text: c.Find,
90+
Text: oldText,
5191
MatchCase: c.MatchCase,
5292
},
53-
ReplaceText: c.ReplaceStr,
93+
ReplaceText: newText,
5494
},
55-
},
56-
}
57-
58-
// Execute batch update
59-
resp, err := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{
60-
Requests: requests,
95+
}},
6196
}).Context(ctx).Do()
6297
if err != nil {
63-
return fmt.Errorf("update document: %w", err)
98+
return fmt.Errorf("edit document: %w", err)
6499
}
65100

66-
// Get count of replacements
67-
replaced := int64(0)
68-
if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil {
69-
replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged
101+
replacements := int64(0)
102+
if len(result.Replies) > 0 && result.Replies[0].ReplaceAllText != nil {
103+
replacements = result.Replies[0].ReplaceAllText.OccurrencesChanged
70104
}
71105

72106
if outfmt.IsJSON(ctx) {
73107
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
74-
"status": "ok",
75-
"docId": id,
76-
"replaced": replaced,
108+
"documentId": result.DocumentId,
109+
"old": oldText,
110+
"new": newText,
111+
"replacements": replacements,
112+
"matchCase": c.MatchCase,
77113
})
78114
}
79115

80-
u.Out().Printf("status\tok")
81-
u.Out().Printf("docId\t%s", id)
82-
u.Out().Printf("replaced\t%d", replaced)
116+
u.Out().Printf("replaced %d occurrence%s", replacements, plural(int(replacements)))
83117
return nil
84118
}

internal/cmd/docs_edit_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,9 @@ func TestDocsEditCmd_EmptyDocId(t *testing.T) {
439439
ctx := ui.WithUI(context.Background(), u)
440440

441441
cmd := &DocsEditCmd{
442-
DocID: " ",
443-
Find: "foo",
444-
ReplaceStr: "bar",
442+
DocID: " ",
443+
Old: "foo",
444+
New: "bar",
445445
}
446446

447447
flags := &RootFlags{Account: "[email protected]"}
@@ -459,9 +459,9 @@ func TestDocsEditCmd_EmptyFind(t *testing.T) {
459459
ctx := ui.WithUI(context.Background(), u)
460460

461461
cmd := &DocsEditCmd{
462-
DocID: "doc123",
463-
Find: "",
464-
ReplaceStr: "bar",
462+
DocID: "doc123",
463+
Old: "",
464+
New: "bar",
465465
}
466466

467467
flags := &RootFlags{Account: "[email protected]"}

0 commit comments

Comments
 (0)