diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7a5c0a..7e416e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - CLI: bound retry request replay buffering, recover failed async backup pushes, ignore global git commit signing in backup snapshots, and protect account manager OAuth redirects with CSRF checks. - Release: update the Homebrew handoff to publish through `openclaw/tap`. - Version: `gog --version` now reports an informative fallback (for example, `v0.17.0-dev`) when built from source with plain `go build` instead of returning `dev`. +- Docs: `gog docs insert` now defaults to end-of-doc when `--index` is omitted, instead of always inserting at position 1 (which silently reversed iterative inserts across multiple calls). Pass `--index 1` explicitly to keep the previous behaviour. (#606) ## 0.17.0 - 2026-05-15 diff --git a/docs/commands/gog-docs-insert.md b/docs/commands/gog-docs-insert.md index 5c049c37..0a4ed70e 100644 --- a/docs/commands/gog-docs-insert.md +++ b/docs/commands/gog-docs-insert.md @@ -29,7 +29,7 @@ gog docs (doc) insert [] [flags] | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | -| `--index` | `int64` | 1 | Character index to insert at (1 = beginning) | +| `--index` | `*int64` | | Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted. | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index aa8f3a7f..93a9e180 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -385,6 +385,8 @@ func listCalendarList(ctx context.Context, svc *calendar.Service) ([]*calendar.C // crossing timezones interleave correctly. String keys (summary, calendar) // compare case-insensitive for summary, exact for calendar id. func sortEventsBy(events []*eventWithCalendar, key, order string) { + const calendarSortEnd = "end" + key = strings.ToLower(strings.TrimSpace(key)) if key == "" || len(events) < 2 { return @@ -392,9 +394,9 @@ func sortEventsBy(events []*eventWithCalendar, key, order string) { desc := strings.ToLower(strings.TrimSpace(order)) == "desc" switch key { - case "start", "end": + case "start", calendarSortEnd: instantFn := eventStartInstant - if key == "end" { + if key == calendarSortEnd { instantFn = eventEndInstant } sort.SliceStable(events, func(i, j int) bool { diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 506dec3b..2bd8ed46 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -485,7 +485,7 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root type DocsInsertCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Content string `arg:"" optional:"" name:"content" help:"Text to insert (or use --file / stdin)"` - Index int64 `name:"index" help:"Character index to insert at (1 = beginning)" default:"1"` + Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted."` File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"` Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` @@ -504,7 +504,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { if content == "" { return usage("no content provided (use argument, --file, or stdin)") } - if c.Index < 1 { + if c.Index != nil && *c.Index < 1 { return usage("--index must be >= 1 (index 0 is reserved)") } @@ -514,12 +514,17 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { } c.Tab = tab - if dryRunErr := dryRunExit(ctx, flags, "docs.insert", map[string]any{ + dryRunPayload := map[string]any{ "documentId": docID, "inserted": len(content), - "atIndex": c.Index, "tab": c.Tab, - }); dryRunErr != nil { + } + if c.Index != nil { + dryRunPayload["atIndex"] = *c.Index + } else { + dryRunPayload["atIndex"] = "end" + } + if dryRunErr := dryRunExit(ctx, flags, "docs.insert", dryRunPayload); dryRunErr != nil { return dryRunErr } @@ -527,12 +532,24 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - if c.Tab != "" { - tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) - if tabErr != nil { - return tabErr + + var insertIndex int64 + if c.Index != nil { + insertIndex = *c.Index + if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID + } + } else { + endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if endErr != nil { + return endErr } c.Tab = tabID + insertIndex = docsAppendIndex(endIndex) } result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ @@ -540,7 +557,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { InsertText: &docs.InsertTextRequest{ Text: content, Location: &docs.Location{ - Index: c.Index, + Index: insertIndex, TabId: c.Tab, }, }, @@ -551,7 +568,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": c.Index} + payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": insertIndex} if c.Tab != "" { payload["tabId"] = c.Tab } @@ -560,7 +577,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { u.Out().Linef("documentId\t%s", result.DocumentId) u.Out().Linef("inserted\t%d bytes", len(content)) - u.Out().Linef("atIndex\t%d", c.Index) + u.Out().Linef("atIndex\t%d", insertIndex) if c.Tab != "" { u.Out().Linef("tabId\t%s", c.Tab) } diff --git a/internal/cmd/docs_insert_default_test.go b/internal/cmd/docs_insert_default_test.go new file mode 100644 index 00000000..c65afd70 --- /dev/null +++ b/internal/cmd/docs_insert_default_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" +) + +// docBodyWithEndIndex returns a Get-response payload whose body endIndex matches +// the provided value, so tests can assert that the insert path resolved +// end-of-doc correctly when --index is omitted. +func docBodyWithEndIndex(end int64) map[string]any { + return map[string]any{ + "documentId": "doc1", + "body": map[string]any{ + "content": []any{ + map[string]any{"startIndex": 0, "endIndex": end}, + }, + }, + } +} + +func TestDocsInsertCmd_DefaultsToEndOfDoc(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docBodyWithEndIndex(42)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello"}, ctx, flags); err != nil { + t.Fatalf("insert: %v", err) + } + + if getCalls != 1 { + t.Fatalf("expected 1 GET to resolve end-index, got %d", getCalls) + } + if len(batchRequests) != 1 || len(batchRequests[0]) != 1 || batchRequests[0][0].InsertText == nil { + t.Fatalf("unexpected requests: %#v", batchRequests) + } + loc := batchRequests[0][0].InsertText.Location + if loc == nil { + t.Fatalf("expected Location, got nil") + } + // endIndex = 42 → docsAppendIndex(42) = 41 + if loc.Index != 41 { + t.Fatalf("expected insert at end-1 (41), got %d", loc.Index) + } +} + +func TestDocsInsertCmd_ExplicitIndexSkipsGet(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docBodyWithEndIndex(42)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello", "--index", "7"}, ctx, flags); err != nil { + t.Fatalf("insert: %v", err) + } + + if getCalls != 0 { + t.Fatalf("explicit --index should not GET the doc, but GET was called %d times", getCalls) + } + if got := batchRequests[0][0].InsertText.Location; got.Index != 7 { + t.Fatalf("expected explicit index 7, got %d", got.Index) + } +} + +func TestDocsInsertCmd_ExplicitIndexBelowOneRejected(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello", "--index", "0"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 1") { + t.Fatalf("expected --index >= 1 validation error, got %v", err) + } +}