Skip to content
Closed
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
32 changes: 28 additions & 4 deletions internal/cmd/gmail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type GmailMessagesSearchCmd struct {
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
Count bool `name:"count" help:"Also report how many messages match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"`
Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"`
Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"`
Expand Down Expand Up @@ -82,10 +83,14 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro

if len(messages) == 0 {
if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"messages": []messageItem{},
"nextPageToken": nextPageToken,
}, 0, c.FailEmpty)
}
if c.Count {
payload["totalMatches"] = int64(0)
}
return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
}
u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
Expand All @@ -106,11 +111,26 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
return err
}

var matchCount gmailMatchCount
countReported := false
if c.Count {
matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) {
return countGmailMessageMatches(ctx, svc, query)
})
if err != nil {
return err
}
}

if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"messages": items,
"nextPageToken": nextPageToken,
}, len(items), c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}

if len(items) == 0 {
Expand All @@ -126,6 +146,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
); err != nil {
return err
}
// stderr, so the table on stdout stays parseable.
if countReported {
printGmailMatchCount(u, len(items), matchCount)
}
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
Expand Down
32 changes: 28 additions & 4 deletions internal/cmd/gmail_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type GmailSearchCmd struct {
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
Count bool `name:"count" help:"Also report how many threads match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"`
Oldest bool `name:"oldest" help:"Show first message date instead of last"`
Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"`
Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
Expand Down Expand Up @@ -67,10 +68,14 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {

if len(threads) == 0 {
if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"threads": []threadItem{},
"nextPageToken": nextPageToken,
}, 0, c.FailEmpty)
}
if c.Count {
payload["totalMatches"] = int64(0)
}
return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
}
u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
Expand All @@ -91,11 +96,26 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

var matchCount gmailMatchCount
countReported := false
if c.Count {
matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) {
return countGmailThreadMatches(ctx, svc, query)
})
if err != nil {
return err
}
}

if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"threads": items,
"nextPageToken": nextPageToken,
}, len(items), c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}

if len(items) == 0 {
Expand All @@ -106,6 +126,10 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
if err := outfmt.WriteTable(ctx, stdoutWriter(ctx), items, gmailThreadColumns()); err != nil {
return err
}
// stderr, so the table on stdout stays parseable.
if countReported {
printGmailMatchCount(u, len(items), matchCount)
}
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
Expand Down
124 changes: 124 additions & 0 deletions internal/cmd/gmail_search_count.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package cmd

import (
"context"
"fmt"

"google.golang.org/api/gmail/v1"

"github.com/openclaw/gogcli/internal/ui"
)

// One maximal page is enough to count all but the broadest result sets, and
// 500 is the ceiling Gmail's list endpoints accept.
const gmailCountProbePageSize = 500

// gmailMatchCount is how large a result set really is.
//
// Exact means the probe reached the end of the set, so Value is the total.
// Otherwise the probe filled its page with more behind it and Value is a lower
// bound — reported as such rather than rounded into a total nobody can trust.
type gmailMatchCount struct {
Value int64
Exact bool
}

// apply writes the count into a JSON payload under the name that matches its
// certainty, so a consumer never has to guess whether a number is a total.
func (c gmailMatchCount) apply(payload map[string]any) {
if c.Exact {
payload["totalMatches"] = c.Value
return
}
payload["totalMatchesAtLeast"] = c.Value
}

// Deliberately NOT Gmail's resultSizeEstimate, which is the obvious source and
// saturates: measured against a live mailbox it returned exactly 201 for every
// non-empty query — from:freshbooks.com (21 real matches), from:housecallpro.com
// (6), from:thumbtack.com newer_than:30d (3) — and 0 for a query with none. It
// is a has-results boolean wearing a number's clothes, and it does not vary
// with maxResults either. Emitting it would let a caller report "3 of ~201"
// when the truth is 3 of 6. See openclaw/gogcli#983.
//
// Counting bare ids costs one extra list call and returns a response of ids
// alone, and it is exact whenever the set fits a single page — the common case,
// and always the case for the narrow queries where a wrong count does the most
// damage.
func countGmailThreadMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) {
opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "")
resp, err := applyGmailThreadListOptions(svc.Users.Threads.List("me"), opts).
Fields("threads/id,nextPageToken").
Context(ctx).
Do()
if err != nil {
return gmailMatchCount{}, err
}
return gmailMatchCount{Value: int64(len(resp.Threads)), Exact: resp.NextPageToken == ""}, nil
}

func countGmailMessageMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) {
opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "")
resp, err := applyGmailMessageListOptions(svc.Users.Messages.List("me"), opts).
Fields("messages/id,nextPageToken").
Context(ctx).
Do()
if err != nil {
return gmailMatchCount{}, err
}
return gmailMatchCount{Value: int64(len(resp.Messages)), Exact: resp.NextPageToken == ""}, nil
}

// The human-facing form of the same fact, on stderr so the table on stdout
// stays parseable. Says outright when the page is not the whole set: a caller
// reading only the visible rows is exactly how a partial result gets reported
// as an absence.
func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) {
if count.Exact {
if int64(shown) < count.Value {
u.Err().Println(fmt.Sprintf("Showing %d of %d matches.", shown, count.Value))
return
}
u.Err().Println(fmt.Sprintf("%d matches.", count.Value))
return
}
u.Err().Println(fmt.Sprintf("Showing %d of at least %d matches.", shown, count.Value))
}

// resolveGmailMatchCount decides how --count is answered for one search, and
// whether it can be answered at all. Three cases, and only one of them is worth
// an extra request:
//
// --results-only The count is an envelope field, and --results-only exists
// to drop the envelope and emit the bare result array — so
// the number would be computed and then thrown away. Say so
// on stderr instead of spending a request in silence.
// --all The walk already exhausted every page, so the items in hand
// ARE the whole set. Probing would ask Google a question we
// just finished answering.
// otherwise Probe.
//
// The count is always for the WHOLE query, never the remainder after a
// --page cursor: "how many match this" is the number that stops a caller
// concluding an absence, and keeping it page-independent means it does not
// drift while paging through a set.
func resolveGmailMatchCount(
u *ui.UI,
resultsOnly, all bool,
shown int,
probe func() (gmailMatchCount, error),
) (gmailMatchCount, bool, error) {
switch {
case resultsOnly:
u.Err().Println("--count has no effect with --results-only: the count is an envelope field, and --results-only emits only the result array. Skipping the extra request.")
return gmailMatchCount{}, false, nil
case all:
return gmailMatchCount{Value: int64(shown), Exact: true}, true, nil
default:
count, err := probe()
if err != nil {
return gmailMatchCount{}, false, err
}
return count, true, nil
}
}
Loading