diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf847e60..8e7d756e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Docs: add `docs insert-image --url` for inserting public HTTPS images directly without Drive upload or temporary public sharing. (#675) — thanks @sebsnyk. - Docs: expose paragraph emptiness and text-run ranges, styles, and links in `docs paragraphs list --json`. (#734) — thanks @sebsnyk. - Docs: add opt-in `--check-orphans` to Markdown replacement writes so open comments whose quoted text would disappear block the mutation with orphaned exit code 11. (#691) — thanks @sebsnyk. +- Drive: add `drive revisions list|get` for paged revision metadata and provider export links. (#672) — thanks @aaroneden. ### Fixed diff --git a/README.md b/README.md index f118edb42..b0c919328 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ gog calendar appointments Docs: [Drive audits](docs/drive-audits.md), [raw API dumps](docs/raw-api.md), [`gog drive`](docs/commands/gog-drive.md), [`drive changes`](docs/commands/gog-drive-changes.md), +[`drive revisions`](docs/commands/gog-drive-revisions.md), [`drive activity`](docs/commands/gog-drive-activity.md). ```bash @@ -203,8 +204,13 @@ gog drive get --fields 'id,name,mimeType,size,owners,emailAddress' --js # Track changes and audit activity. gog drive changes start-token gog drive changes list --token --json +gog drive revisions list --all --json +gog drive revisions get --json gog drive activity query --file --actions edit,share --from 2026-01-01T00:00:00Z --json +# The Drive API exposes revision metadata and provider export links. For native +# Docs Editors files, it does not expose complete editor history or historical bodies. + # Lossless raw API JSON. gog drive raw --pretty ``` diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 0490d6022..b95da17c6 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -328,6 +328,9 @@ Generated from `gog schema --json`. - [`gog drive (drv) permissions [flags]`](commands/gog-drive-permissions.md) - List permissions on a file - [`gog drive (drv) raw [flags]`](commands/gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [`gog drive (drv) rename `](commands/gog-drive-rename.md) - Rename a file or folder + - [`gog drive (drv) revisions (revision) `](commands/gog-drive-revisions.md) - List and inspect file revisions + - [`gog drive (drv) revisions (revision) get `](commands/gog-drive-revisions-get.md) - Get revision metadata + - [`gog drive (drv) revisions (revision) list (ls) [flags]`](commands/gog-drive-revisions-list.md) - List revisions for a file - [`gog drive (drv) search ... [flags]`](commands/gog-drive-search.md) - Full-text search across Drive - [`gog drive (drv) share [flags]`](commands/gog-drive-share.md) - Share a file or folder - [`gog drive (drv) tree [flags]`](commands/gog-drive-tree.md) - Print a read-only folder tree diff --git a/docs/commands/README.md b/docs/commands/README.md index 593b2418d..081c9175f 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 615. +Generated pages: 618. ## Top-level Commands @@ -380,6 +380,9 @@ Generated pages: 615. - [gog drive permissions](gog-drive-permissions.md) - List permissions on a file - [gog drive raw](gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [gog drive rename](gog-drive-rename.md) - Rename a file or folder + - [gog drive revisions](gog-drive-revisions.md) - List and inspect file revisions + - [gog drive revisions get](gog-drive-revisions-get.md) - Get revision metadata + - [gog drive revisions list](gog-drive-revisions-list.md) - List revisions for a file - [gog drive search](gog-drive-search.md) - Full-text search across Drive - [gog drive share](gog-drive-share.md) - Share a file or folder - [gog drive tree](gog-drive-tree.md) - Print a read-only folder tree diff --git a/docs/commands/gog-drive-revisions-get.md b/docs/commands/gog-drive-revisions-get.md new file mode 100644 index 000000000..ee278337d --- /dev/null +++ b/docs/commands/gog-drive-revisions-get.md @@ -0,0 +1,45 @@ +# `gog drive revisions get` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Get revision metadata + +## Usage + +```bash +gog drive (drv) revisions (revision) get +``` + +## Parent + +- [gog drive revisions](gog-drive-revisions.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-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. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-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) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog drive revisions](gog-drive-revisions.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive-revisions-list.md b/docs/commands/gog-drive-revisions-list.md new file mode 100644 index 000000000..a74aee0ed --- /dev/null +++ b/docs/commands/gog-drive-revisions-list.md @@ -0,0 +1,49 @@ +# `gog drive revisions list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List revisions for a file + +## Usage + +```bash +gog drive (drv) revisions (revision) list (ls) [flags] +``` + +## Parent + +- [gog drive revisions](gog-drive-revisions.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `--fail-empty`
`--non-empty`
`--require-results` | `bool` | | Exit with code 3 if no revisions | +| `-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. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max`
`--limit` | `int64` | 200 | Max results | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--page`
`--cursor` | `string` | | Page token | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog drive revisions](gog-drive-revisions.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive-revisions.md b/docs/commands/gog-drive-revisions.md new file mode 100644 index 000000000..075a1e5bc --- /dev/null +++ b/docs/commands/gog-drive-revisions.md @@ -0,0 +1,50 @@ +# `gog drive revisions` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List and inspect file revisions + +## Usage + +```bash +gog drive (drv) revisions (revision) +``` + +## Parent + +- [gog drive](gog-drive.md) + +## Subcommands + +- [gog drive revisions get](gog-drive-revisions-get.md) - Get revision metadata +- [gog drive revisions list](gog-drive-revisions-list.md) - List revisions for a file + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-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. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-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) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog drive](gog-drive.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive.md b/docs/commands/gog-drive.md index 71979a8fb..3823ef025 100644 --- a/docs/commands/gog-drive.md +++ b/docs/commands/gog-drive.md @@ -35,6 +35,7 @@ gog drive (drv) [flags] - [gog drive permissions](gog-drive-permissions.md) - List permissions on a file - [gog drive raw](gog-drive-raw.md) - Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption) - [gog drive rename](gog-drive-rename.md) - Rename a file or folder +- [gog drive revisions](gog-drive-revisions.md) - List and inspect file revisions - [gog drive search](gog-drive-search.md) - Full-text search across Drive - [gog drive share](gog-drive-share.md) - Share a file or folder - [gog drive tree](gog-drive-tree.md) - Print a read-only folder tree diff --git a/internal/cmd/backup_drive_collaboration.go b/internal/cmd/backup_drive_collaboration.go index 42d73176a..2ade6d9cf 100644 --- a/internal/cmd/backup_drive_collaboration.go +++ b/internal/cmd/backup_drive_collaboration.go @@ -200,7 +200,7 @@ func fetchBackupDriveRevisions(ctx context.Context, svc *drive.Service, fileID s for { call := svc.Revisions.List(fileID). PageSize(200). - Fields(gapi.Field("nextPageToken, revisions(id,mimeType,modifiedTime,keepForever,published,publishAuto,publishedOutsideDomain,publishedLink,lastModifyingUser,md5Checksum,size,originalFilename,exportLinks)")). + Fields(gapi.Field(driveRevisionListFields)). Context(ctx) if pageToken != "" { call = call.PageToken(pageToken) diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index c875eacd8..b53e0cedf 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -80,6 +80,7 @@ type DriveCmd struct { URL DriveURLCmd `cmd:"" name:"url" help:"Print web URLs for files"` Comments DriveCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` Drives DriveDrivesCmd `cmd:"" name:"drives" help:"List shared drives (Team Drives)"` + Revisions DriveRevisionsCmd `cmd:"" name:"revisions" aliases:"revision" help:"List and inspect file revisions"` Changes DriveChangesCmd `cmd:"" name:"changes" help:"Track Drive changes for sync and automation"` Activity DriveActivityCmd `cmd:"" name:"activity" help:"Query Drive Activity audit events"` Raw DriveRawCmd `cmd:"" name:"raw" help:"Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption)"` diff --git a/internal/cmd/drive_revisions.go b/internal/cmd/drive_revisions.go new file mode 100644 index 000000000..2ee48758e --- /dev/null +++ b/internal/cmd/drive_revisions.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "google.golang.org/api/drive/v3" + gapi "google.golang.org/api/googleapi" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const ( + driveRevisionFields = "id,mimeType,modifiedTime,keepForever,published,publishAuto,publishedOutsideDomain,publishedLink,lastModifyingUser,md5Checksum,size,originalFilename,exportLinks" + driveRevisionListFields = "nextPageToken,revisions(" + driveRevisionFields + ")" +) + +type DriveRevisionsCmd struct { + List DriveRevisionsListCmd `cmd:"" name:"list" aliases:"ls" help:"List revisions for a file"` + Get DriveRevisionsGetCmd `cmd:"" name:"get" help:"Get revision metadata"` +} + +type DriveRevisionsListCmd struct { + FileID string `arg:"" name:"fileId" help:"File ID"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"200"` + 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 revisions"` +} + +func (c *DriveRevisionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + fileID := strings.TrimSpace(c.FileID) + if fileID == "" { + return usage("empty fileId") + } + if c.Max <= 0 { + return usage("max must be > 0") + } + + _, svc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + + fetch := func(pageToken string) ([]*drive.Revision, string, error) { + call := svc.Revisions.List(fileID). + PageSize(c.Max). + Fields(gapi.Field(driveRevisionListFields)). + Context(ctx) + if page := strings.TrimSpace(pageToken); page != "" { + call = call.PageToken(page) + } + resp, callErr := call.Do() + if callErr != nil { + return nil, "", callErr + } + return resp.Revisions, resp.NextPageToken, nil + } + + revisions, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch) + if err != nil { + return err + } + if revisions == nil { + revisions = []*drive.Revision{} + } + + if outfmt.IsJSON(ctx) { + return writePagedJSONResult(ctx, map[string]any{ + "fileId": fileID, + "revisions": revisions, + "nextPageToken": nextPageToken, + }, len(revisions), c.FailEmpty) + } + if len(revisions) == 0 { + u.Err().Println("No revisions") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tMODIFIED\tMIME\tUSER\tSIZE\tKEEP\tPUBLISHED\tEXPORTS") + for _, revision := range revisions { + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\t%t\t%t\t%s\n", + revision.Id, + formatDateTime(revision.ModifiedTime), + revision.MimeType, + sanitizeTab(driveRevisionUser(revision)), + formatDriveSize(revision.Size), + revision.KeepForever, + revision.Published, + sanitizeTab(strings.Join(driveRevisionExportMIMEs(revision), ",")), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +type DriveRevisionsGetCmd struct { + FileID string `arg:"" name:"fileId" help:"File ID"` + RevisionID string `arg:"" name:"revisionId" help:"Revision ID"` +} + +func (c *DriveRevisionsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + fileID := strings.TrimSpace(c.FileID) + if fileID == "" { + return usage("empty fileId") + } + revisionID := strings.TrimSpace(c.RevisionID) + if revisionID == "" { + return usage("empty revisionId") + } + + _, svc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + revision, err := svc.Revisions.Get(fileID, revisionID). + Fields(gapi.Field(driveRevisionFields)). + Context(ctx). + Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "fileId": fileID, + "revision": revision, + }) + } + + u.Out().Linef("fileId\t%s", fileID) + u.Out().Linef("id\t%s", revision.Id) + u.Out().Linef("modified\t%s", revision.ModifiedTime) + u.Out().Linef("mime\t%s", revision.MimeType) + if user := driveRevisionUser(revision); user != "" { + u.Out().Linef("user\t%s", sanitizeTab(user)) + } + if revision.LastModifyingUser != nil && revision.LastModifyingUser.EmailAddress != "" { + u.Out().Linef("userEmail\t%s", revision.LastModifyingUser.EmailAddress) + } + u.Out().Linef("size\t%s", formatDriveSize(revision.Size)) + u.Out().Linef("keepForever\t%t", revision.KeepForever) + u.Out().Linef("published\t%t", revision.Published) + u.Out().Linef("publishAuto\t%t", revision.PublishAuto) + u.Out().Linef("publishedOutsideDomain\t%t", revision.PublishedOutsideDomain) + if revision.PublishedLink != "" { + u.Out().Linef("publishedLink\t%s", revision.PublishedLink) + } + if revision.Md5Checksum != "" { + u.Out().Linef("md5\t%s", revision.Md5Checksum) + } + if revision.OriginalFilename != "" { + u.Out().Linef("originalFilename\t%s", sanitizeTab(revision.OriginalFilename)) + } + for _, mimeType := range driveRevisionExportMIMEs(revision) { + u.Out().Linef("export.%s\t%s", mimeType, revision.ExportLinks[mimeType]) + } + return nil +} + +func driveRevisionUser(revision *drive.Revision) string { + if revision == nil || revision.LastModifyingUser == nil { + return "" + } + if displayName := strings.TrimSpace(revision.LastModifyingUser.DisplayName); displayName != "" { + return displayName + } + return strings.TrimSpace(revision.LastModifyingUser.EmailAddress) +} + +func driveRevisionExportMIMEs(revision *drive.Revision) []string { + if revision == nil || len(revision.ExportLinks) == 0 { + return nil + } + mimeTypes := make([]string, 0, len(revision.ExportLinks)) + for mimeType := range revision.ExportLinks { + mimeTypes = append(mimeTypes, mimeType) + } + sort.Strings(mimeTypes) + return mimeTypes +} diff --git a/internal/cmd/drive_revisions_test.go b/internal/cmd/drive_revisions_test.go new file mode 100644 index 000000000..3d72de981 --- /dev/null +++ b/internal/cmd/drive_revisions_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestDriveRevisionsListCmd_AllPagesJSON(t *testing.T) { + var calls int + svc, closeSvc := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || strings.TrimPrefix(r.URL.Path, "/drive/v3") != "/files/file1/revisions" { + http.NotFound(w, r) + return + } + calls++ + requireQuery(t, r, "pageSize", "2") + if fields := r.URL.Query().Get("fields"); !strings.Contains(fields, "exportLinks") { + t.Fatalf("missing exportLinks field: %q", fields) + } + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + requireQuery(t, r, "pageToken", "") + _, _ = io.WriteString(w, `{"revisions":[{"id":"r1"},{"id":"r2"}],"nextPageToken":"p2"}`) + return + } + requireQuery(t, r, "pageToken", "p2") + _, _ = io.WriteString(w, `{"revisions":[{"id":"r3"}]}`) + })) + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + ctx := newDriveRevisionsTestContext(t, true, io.Discard, io.Discard) + stdout := captureStdout(t, func() { + cmd := &DriveRevisionsListCmd{} + if err := runKong(t, cmd, []string{"file1", "--max", "2", "--all"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + var result struct { + FileID string `json:"fileId"` + Revisions []*drive.Revision `json:"revisions"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("json: %v\n%s", err, stdout) + } + if result.FileID != "file1" || len(result.Revisions) != 3 || result.NextPageToken != "" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestDriveRevisionsListCmd_Text(t *testing.T) { + svc, closeSvc := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "revisions":[{ + "id":"r1", + "modifiedTime":"2026-06-10T12:34:56Z", + "mimeType":"application/vnd.google-apps.document", + "lastModifyingUser":{"displayName":"Alice"}, + "exportLinks":{"text/plain":"https://example.test/txt","application/pdf":"https://example.test/pdf"}, + "published":true + }], + "nextPageToken":"next" + }`) + })) + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + var stderr bytes.Buffer + ctx := newDriveRevisionsTestContext(t, false, io.Discard, &stderr) + stdout := captureStdout(t, func() { + cmd := &DriveRevisionsListCmd{} + if err := runKong(t, cmd, []string{"file1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + for _, want := range []string{"ID MODIFIED", "MIME", "r1", "Alice", "application/pdf,text/plain", "true"} { + if !strings.Contains(stdout, want) { + t.Fatalf("missing %q in %q", want, stdout) + } + } + if !strings.Contains(stderr.String(), "--page next") { + t.Fatalf("missing next-page hint: %q", stderr.String()) + } +} + +func TestDriveRevisionsListCmd_EmptyJSON(t *testing.T) { + svc, closeSvc := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{}`) + })) + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + ctx := newDriveRevisionsTestContext(t, true, io.Discard, io.Discard) + stdout := captureStdout(t, func() { + cmd := &DriveRevisionsListCmd{} + if err := runKong(t, cmd, []string{"file1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + if !strings.Contains(stdout, `"revisions": []`) { + t.Fatalf("expected empty array, got: %q", stdout) + } +} + +func TestDriveRevisionsGetCmd_JSONAndText(t *testing.T) { + svc, closeSvc := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || strings.TrimPrefix(r.URL.Path, "/drive/v3") != "/files/file1/revisions/r2" { + http.NotFound(w, r) + return + } + if fields := r.URL.Query().Get("fields"); !strings.Contains(fields, "exportLinks") { + t.Fatalf("missing exportLinks field: %q", fields) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "id":"r2", + "modifiedTime":"2026-06-11T10:00:00Z", + "mimeType":"application/vnd.google-apps.document", + "lastModifyingUser":{"displayName":"Alice","emailAddress":"alice@example.com"}, + "exportLinks":{"text/plain":"https://example.test/txt","application/pdf":"https://example.test/pdf"} + }`) + })) + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + jsonCtx := newDriveRevisionsTestContext(t, true, io.Discard, io.Discard) + jsonOut := captureStdout(t, func() { + cmd := &DriveRevisionsGetCmd{} + if err := runKong(t, cmd, []string{"file1", "r2"}, jsonCtx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("JSON Run: %v", err) + } + }) + var result struct { + FileID string `json:"fileId"` + Revision *drive.Revision `json:"revision"` + } + if err := json.Unmarshal([]byte(jsonOut), &result); err != nil { + t.Fatalf("json: %v\n%s", err, jsonOut) + } + if result.FileID != "file1" || result.Revision == nil || result.Revision.Id != "r2" { + t.Fatalf("unexpected result: %#v", result) + } + + var textBuffer bytes.Buffer + textCtx := newDriveRevisionsTestContext(t, false, &textBuffer, io.Discard) + cmd := &DriveRevisionsGetCmd{} + if err := runKong(t, cmd, []string{"file1", "r2"}, textCtx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("text Run: %v", err) + } + textOut := textBuffer.String() + if strings.Index(textOut, "export.application/pdf") > strings.Index(textOut, "export.text/plain") { + t.Fatalf("export links are not sorted: %q", textOut) + } + for _, want := range []string{"fileId\tfile1", "id\tr2", "userEmail\talice@example.com", "https://example.test/pdf"} { + if !strings.Contains(textOut, want) { + t.Fatalf("missing %q in %q", want, textOut) + } + } +} + +func TestDriveRevisionsValidationBeforeAuth(t *testing.T) { + called := false + original := newDriveService + newDriveService = func(context.Context, string) (*drive.Service, error) { + called = true + return nil, errors.New("unexpected service call") + } + t.Cleanup(func() { newDriveService = original }) + + ctx := newDriveRevisionsTestContext(t, false, io.Discard, io.Discard) + if err := (&DriveRevisionsListCmd{FileID: "file1", Max: 0}).Run(ctx, &RootFlags{}); err == nil || !strings.Contains(err.Error(), "max must be > 0") { + t.Fatalf("unexpected list error: %v", err) + } + if err := (&DriveRevisionsGetCmd{FileID: "file1"}).Run(ctx, &RootFlags{}); err == nil || !strings.Contains(err.Error(), "empty revisionId") { + t.Fatalf("unexpected get error: %v", err) + } + if called { + t.Fatal("validation should happen before auth") + } +} + +func newDriveRevisionsTestContext(t *testing.T, jsonMode bool, stdout, stderr io.Writer) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: stdout, Stderr: stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + return outfmt.WithMode(ctx, outfmt.Mode{JSON: jsonMode}) +}