diff --git a/CHANGELOG.md b/CHANGELOG.md index 33325bdc..7d5ec7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Raw API dumps: add `docs raw`, `sheets raw`, `slides raw`, `drive raw`, `gmail raw`, `calendar raw`, `people raw`, `contacts raw`, `tasks raw`, and `forms raw` subcommands for lossless Google API JSON output, with `--pretty`, Drive raw redaction defaults, Sheets grid-data warnings, and a raw-output security audit. (#495, #496) — thanks @karbassi. - Docs: add `docs format` and plain-text `docs write` formatting flags for fonts, colors, bold/italic/underline/strikethrough, alignment, and line spacing. (#479) — thanks @mmaghsoodnia. - Drive: add `--fields` to `drive ls` and `drive get` so callers can pass Drive API field masks for fields beyond the default JSON set. (#495) — thanks @karbassi. +- Drive: add read-only `drive tree`, `drive du`, and `drive inventory` reports for auditing folder contents and sizes. (#116) — thanks @rohan-patnaik. - Sheets: add `sheets table` list/get/create/delete commands for Google Sheets structured tables. (#470) — thanks @Pedrohgv. - Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield. - Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal. diff --git a/README.md b/README.md index 22a45ad9..03a910e8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Calendar** - list/create/update/delete events, manage invitations, aliases, subscriptions, team calendars, free/busy/conflicts, propose new times, focus/OOO/working-location events, recurrence, and reminders - **Classroom** - manage courses, roster, coursework/materials, submissions, announcements, topics, invitations, guardians, profiles - **Chat** - list/find/create spaces, list messages/threads, send messages and DMs, and manage emoji reactions (Workspace-only) -- **Drive** - list/search/upload/download files, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives +- **Drive** - list/search/upload/download files, inspect folders with tree/du/inventory reports, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives - **Contacts** - search/create/update contacts, including addresses, relations, org/title metadata, custom fields, Workspace directory, and other contacts - **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases - **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs, named ranges, and Sheets tables, format/merge/freeze/resize cells, manage conditional formatting and banding, read/write notes, inspect formats, find/replace text, list links, and create/export sheets @@ -1132,6 +1132,12 @@ gog drive get # Get file metadata gog drive url # Print Drive web URL gog drive copy "Copy Name" +# Read-only reports +gog drive tree --parent --depth 2 +gog drive inventory --parent --max 500 --sort modified --order desc +gog drive du --parent --depth 1 --max 50 +gog --json drive inventory --parent --max 100 | jq '.items[] | {path,size,modifiedTime}' + # Upload and download gog drive upload ./path/to/file --parent gog drive upload ./path/to/file --replace # Replace file content in-place (preserves shared link) diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 6c6b205f..6806f050 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -245,7 +245,9 @@ Generated from `gog schema --json`. - [`gog drive (drv) delete (rm,del) [flags]`](commands/gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever) - [`gog drive (drv) download [flags]`](commands/gog-drive-download.md) - Download a file (exports Google Docs formats) - [`gog drive (drv) drives [flags]`](commands/gog-drive-drives.md) - List shared drives (Team Drives) + - [`gog drive (drv) du [flags]`](commands/gog-drive-du.md) - Summarize Drive folder sizes - [`gog drive (drv) get [flags]`](commands/gog-drive-get.md) - Get file metadata + - [`gog drive (drv) inventory [flags]`](commands/gog-drive-inventory.md) - Export a read-only Drive inventory - [`gog drive (drv) ls [flags]`](commands/gog-drive-ls.md) - List files in a folder (default: root) - [`gog drive (drv) mkdir [flags]`](commands/gog-drive-mkdir.md) - Create a folder - [`gog drive (drv) move [flags]`](commands/gog-drive-move.md) - Move a file to a different folder @@ -254,6 +256,7 @@ Generated from `gog schema --json`. - [`gog drive (drv) rename `](commands/gog-drive-rename.md) - Rename a file or folder - [`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 - [`gog drive (drv) unshare `](commands/gog-drive-unshare.md) - Remove a permission from a file - [`gog drive (drv) upload [flags]`](commands/gog-drive-upload.md) - Upload a file - [`gog drive (drv) url ...`](commands/gog-drive-url.md) - Print web URLs for files diff --git a/docs/commands/README.md b/docs/commands/README.md index 74b65c18..34e80918 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: 466. +Generated pages: 469. ## Top-level Commands @@ -288,7 +288,9 @@ Generated pages: 466. - [gog drive delete](gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever) - [gog drive download](gog-drive-download.md) - Download a file (exports Google Docs formats) - [gog drive drives](gog-drive-drives.md) - List shared drives (Team Drives) + - [gog drive du](gog-drive-du.md) - Summarize Drive folder sizes - [gog drive get](gog-drive-get.md) - Get file metadata + - [gog drive inventory](gog-drive-inventory.md) - Export a read-only Drive inventory - [gog drive ls](gog-drive-ls.md) - List files in a folder (default: root) - [gog drive mkdir](gog-drive-mkdir.md) - Create a folder - [gog drive move](gog-drive-move.md) - Move a file to a different folder @@ -297,6 +299,7 @@ Generated pages: 466. - [gog drive rename](gog-drive-rename.md) - Rename a file or folder - [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 - [gog drive unshare](gog-drive-unshare.md) - Remove a permission from a file - [gog drive upload](gog-drive-upload.md) - Upload a file - [gog drive url](gog-drive-url.md) - Print web URLs for files diff --git a/docs/commands/gog-drive-du.md b/docs/commands/gog-drive-du.md new file mode 100644 index 00000000..5e4b8996 --- /dev/null +++ b/docs/commands/gog-drive-du.md @@ -0,0 +1,48 @@ +# `gog drive du` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Summarize Drive folder sizes + +## Usage + +```bash +gog drive (drv) du [flags] +``` + +## Parent + +- [gog drive](gog-drive.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--depth` | `int` | 1 | Depth for folder totals | +| `--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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max` | `int` | 50 | Max folders to return (0 = unlimited) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--order` | `string` | desc | Sort order | +| `--parent` | `string` | | Folder ID to start from (default: root) | +| `-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. | +| `--sort` | `string` | size | Sort by size\|path\|files | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog drive](gog-drive.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive-inventory.md b/docs/commands/gog-drive-inventory.md new file mode 100644 index 00000000..04491693 --- /dev/null +++ b/docs/commands/gog-drive-inventory.md @@ -0,0 +1,48 @@ +# `gog drive inventory` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Export a read-only Drive inventory + +## Usage + +```bash +gog drive (drv) inventory [flags] +``` + +## Parent + +- [gog drive](gog-drive.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--depth` | `int` | 0 | Max depth (0 = unlimited) | +| `--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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max` | `int` | 500 | Max items to return (0 = unlimited) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--order` | `string` | asc | Sort order | +| `--parent` | `string` | | Folder ID to start from (default: root) | +| `-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. | +| `--sort` | `string` | path | Sort by path\|size\|modified | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog drive](gog-drive.md) +- [Command index](README.md) diff --git a/docs/commands/gog-drive-tree.md b/docs/commands/gog-drive-tree.md new file mode 100644 index 00000000..1649f998 --- /dev/null +++ b/docs/commands/gog-drive-tree.md @@ -0,0 +1,46 @@ +# `gog drive tree` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Print a read-only folder tree + +## Usage + +```bash +gog drive (drv) tree [flags] +``` + +## Parent + +- [gog drive](gog-drive.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--depth` | `int` | 2 | Max depth (0 = unlimited) | +| `--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 commands; dot paths allowed (restricts CLI) | +| `-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. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max` | `int` | 0 | Max items to return (0 = unlimited) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--parent` | `string` | | Folder ID to start from (default: root) | +| `-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 | + +## 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 8dd3caf7..59a28360 100644 --- a/docs/commands/gog-drive.md +++ b/docs/commands/gog-drive.md @@ -21,7 +21,9 @@ gog drive (drv) [flags] - [gog drive delete](gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever) - [gog drive download](gog-drive-download.md) - Download a file (exports Google Docs formats) - [gog drive drives](gog-drive-drives.md) - List shared drives (Team Drives) +- [gog drive du](gog-drive-du.md) - Summarize Drive folder sizes - [gog drive get](gog-drive-get.md) - Get file metadata +- [gog drive inventory](gog-drive-inventory.md) - Export a read-only Drive inventory - [gog drive ls](gog-drive-ls.md) - List files in a folder (default: root) - [gog drive mkdir](gog-drive-mkdir.md) - Create a folder - [gog drive move](gog-drive-move.md) - Move a file to a different folder @@ -30,6 +32,7 @@ gog drive (drv) [flags] - [gog drive rename](gog-drive-rename.md) - Rename a file or folder - [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 - [gog drive unshare](gog-drive-unshare.md) - Remove a permission from a file - [gog drive upload](gog-drive-upload.md) - Upload a file - [gog drive url](gog-drive-url.md) - Print web URLs for files diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 73582fcf..18a857e5 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -33,6 +33,8 @@ var ( ) const ( + driveRootID = "root" + driveMimeFolder = "application/vnd.google-apps.folder" driveMimeGoogleDoc = "application/vnd.google-apps.document" driveMimeGoogleSheet = "application/vnd.google-apps.spreadsheet" driveMimeGoogleSlides = "application/vnd.google-apps.presentation" @@ -70,6 +72,9 @@ const ( type DriveCmd struct { Ls DriveLsCmd `cmd:"" name:"ls" help:"List files in a folder (default: root)"` Search DriveSearchCmd `cmd:"" name:"search" help:"Full-text search across Drive"` + Tree DriveTreeCmd `cmd:"" name:"tree" help:"Print a read-only folder tree"` + Du DriveDuCmd `cmd:"" name:"du" help:"Summarize Drive folder sizes"` + Inventory DriveInventoryCmd `cmd:"" name:"inventory" help:"Export a read-only Drive inventory"` Get DriveGetCmd `cmd:"" name:"get" help:"Get file metadata"` Download DriveDownloadCmd `cmd:"" name:"download" help:"Download a file (exports Google Docs formats)"` Copy DriveCopyCmd `cmd:"" name:"copy" help:"Copy a file"` @@ -945,7 +950,7 @@ func escapeDriveQueryString(s string) string { } func driveType(mimeType string) string { - if mimeType == "application/vnd.google-apps.folder" { + if mimeType == driveMimeFolder { return "folder" } return strFile diff --git a/internal/cmd/drive_reporting.go b/internal/cmd/drive_reporting.go new file mode 100644 index 00000000..415ff56f --- /dev/null +++ b/internal/cmd/drive_reporting.go @@ -0,0 +1,413 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path" + "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 driveDefaultPageSize = 1000 + +type DriveTreeCmd struct { + Parent string `name:"parent" help:"Folder ID to start from (default: root)"` + Depth int `name:"depth" help:"Max depth (0 = unlimited)" default:"2"` + Max int `name:"max" help:"Max items to return (0 = unlimited)" default:"0"` + AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"` +} + +func (c *DriveTreeCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + rootID := strings.TrimSpace(c.Parent) + if rootID == "" { + rootID = driveRootID + } + depth := c.Depth + if depth < 0 { + depth = 0 + } + maxItems := c.Max + if maxItems < 0 { + maxItems = 0 + } + + _, svc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + + items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{ + RootID: rootID, + MaxDepth: depth, + MaxItems: maxItems, + Fields: driveTreeFields, + IncludeFiles: true, + IncludeFolder: true, + AllDrives: c.AllDrives, + }) + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": items, + "truncated": truncated, + }) + } + + if len(items) == 0 { + u.Err().Println("No files") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PATH\tTYPE\tSIZE\tMODIFIED\tID") + for _, it := range items { + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(it.Path), + driveType(it.MimeType), + formatDriveSize(it.Size), + formatDateTime(it.ModifiedTime), + it.ID, + ) + } + if truncated { + u.Err().Println("Results truncated; increase --max to see more.") + } + return nil +} + +type DriveInventoryCmd struct { + Parent string `name:"parent" help:"Folder ID to start from (default: root)"` + Depth int `name:"depth" help:"Max depth (0 = unlimited)" default:"0"` + Max int `name:"max" help:"Max items to return (0 = unlimited)" default:"500"` + Sort string `name:"sort" help:"Sort by path|size|modified" enum:"path,size,modified" default:"path"` + Order string `name:"order" help:"Sort order" enum:"asc,desc" default:"asc"` + AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"` +} + +func (c *DriveInventoryCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + rootID := strings.TrimSpace(c.Parent) + if rootID == "" { + rootID = driveRootID + } + depth := c.Depth + if depth < 0 { + depth = 0 + } + maxItems := c.Max + if maxItems < 0 { + maxItems = 0 + } + + _, svc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + + items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{ + RootID: rootID, + MaxDepth: depth, + MaxItems: maxItems, + Fields: driveInventoryFields, + IncludeFiles: true, + IncludeFolder: true, + AllDrives: c.AllDrives, + }) + if err != nil { + return err + } + + sortDriveInventory(items, c.Sort, c.Order) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": items, + "truncated": truncated, + }) + } + + if len(items) == 0 { + u.Err().Println("No files") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PATH\tTYPE\tSIZE\tMODIFIED\tOWNER\tID") + for _, it := range items { + owner := "-" + if len(it.Owners) > 0 { + owner = it.Owners[0] + } + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(it.Path), + driveType(it.MimeType), + formatDriveSize(it.Size), + formatDateTime(it.ModifiedTime), + owner, + it.ID, + ) + } + if truncated { + u.Err().Println("Results truncated; increase --max to see more.") + } + return nil +} + +type DriveDuCmd struct { + Parent string `name:"parent" help:"Folder ID to start from (default: root)"` + Depth int `name:"depth" help:"Depth for folder totals" default:"1"` + Max int `name:"max" help:"Max folders to return (0 = unlimited)" default:"50"` + Sort string `name:"sort" help:"Sort by size|path|files" enum:"size,path,files" default:"size"` + Order string `name:"order" help:"Sort order" enum:"asc,desc" default:"desc"` + AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"` +} + +func (c *DriveDuCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + rootID := strings.TrimSpace(c.Parent) + if rootID == "" { + rootID = driveRootID + } + depth := c.Depth + if depth < 0 { + depth = 0 + } + maxItems := c.Max + if maxItems < 0 { + maxItems = 0 + } + + _, svc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + + items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{ + RootID: rootID, + MaxDepth: 0, + MaxItems: 0, + Fields: driveTreeFields, + IncludeFiles: true, + IncludeFolder: true, + AllDrives: c.AllDrives, + }) + if err != nil { + return err + } + if truncated { + return fmt.Errorf("drive du truncated unexpectedly") + } + + summaries := summarizeDriveDu(items, rootID, depth) + sortDriveDu(summaries, c.Sort, c.Order) + + if maxItems > 0 && len(summaries) > maxItems { + summaries = summaries[:maxItems] + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "folders": summaries, + }) + } + + if len(summaries) == 0 { + u.Err().Println("No folders") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PATH\tSIZE\tFILES") + for _, f := range summaries { + fmt.Fprintf(w, "%s\t%s\t%d\n", sanitizeTab(f.Path), formatDriveSize(f.Size), f.Files) + } + return nil +} + +type driveTreeItem struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + ParentID string `json:"parentId,omitempty"` + MimeType string `json:"mimeType"` + Size int64 `json:"size,omitempty"` + ModifiedTime string `json:"modifiedTime,omitempty"` + Owners []string `json:"owners,omitempty"` + MD5 string `json:"md5,omitempty"` + Depth int `json:"depth"` +} + +func (d driveTreeItem) IsFolder() bool { + return d.MimeType == driveMimeFolder +} + +type driveTreeOptions struct { + RootID string + MaxDepth int + MaxItems int + Fields string + IncludeFiles bool + IncludeFolder bool + AllDrives bool +} + +type driveFolderQueueItem struct { + ID string + Path string + Depth int +} + +const ( + driveTreeFields = "id,name,mimeType,size,modifiedTime" + driveInventoryFields = "id,name,mimeType,size,modifiedTime,owners(emailAddress,displayName)" +) + +func listDriveTree(ctx context.Context, svc *drive.Service, opts driveTreeOptions) ([]driveTreeItem, bool, error) { + rootID := strings.TrimSpace(opts.RootID) + if rootID == "" { + rootID = driveRootID + } + fields := strings.TrimSpace(opts.Fields) + if fields == "" { + fields = driveTreeFields + } + + queue := []driveFolderQueueItem{{ID: rootID, Path: "", Depth: 0}} + out := make([]driveTreeItem, 0, 128) + truncated := false + + for len(queue) > 0 { + folder := queue[0] + queue = queue[1:] + + children, err := listDriveChildren(ctx, svc, folder.ID, fields, opts.AllDrives) + if err != nil { + return nil, false, err + } + for _, child := range children { + if child == nil { + continue + } + depth := folder.Depth + 1 + item := driveTreeItem{ + ID: child.Id, + Name: child.Name, + Path: joinDrivePath(folder.Path, child.Name), + ParentID: folder.ID, + MimeType: child.MimeType, + Size: child.Size, + ModifiedTime: child.ModifiedTime, + Owners: driveOwners(child), + MD5: child.Md5Checksum, + Depth: depth, + } + + if item.IsFolder() { + if opts.IncludeFolder { + out = append(out, item) + } + if opts.MaxDepth <= 0 || depth < opts.MaxDepth { + queue = append(queue, driveFolderQueueItem{ID: child.Id, Path: item.Path, Depth: depth}) + } + } else if opts.IncludeFiles { + out = append(out, item) + } + + if opts.MaxItems > 0 && len(out) >= opts.MaxItems { + truncated = true + return out, truncated, nil + } + } + } + + return out, truncated, nil +} + +func listDriveChildren(ctx context.Context, svc *drive.Service, parentID string, fields string, allDrives bool) ([]*drive.File, error) { + if parentID == "" { + parentID = driveRootID + } + q := buildDriveListQuery(parentID, "") + out := make([]*drive.File, 0, 64) + var pageToken string + + for { + call := svc.Files.List(). + Q(q). + PageSize(driveDefaultPageSize). + PageToken(pageToken). + OrderBy("folder,name") + call = driveFilesListCallWithDriveSupport(call, allDrives, "") + call = call.Fields( + gapi.Field("nextPageToken"), + gapi.Field("files("+fields+")"), + ).Context(ctx) + resp, err := call.Do() + if err != nil { + return nil, err + } + out = append(out, resp.Files...) + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return out, nil +} + +func joinDrivePath(parent string, name string) string { + name = sanitizeDriveName(name) + if parent == "" { + return name + } + return path.Join(parent, name) +} + +func sanitizeDriveName(name string) string { + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return "_" + } + return name +} + +func driveOwners(f *drive.File) []string { + if f == nil || len(f.Owners) == 0 { + return nil + } + out := make([]string, 0, len(f.Owners)) + for _, owner := range f.Owners { + if owner == nil { + continue + } + if owner.EmailAddress != "" { + out = append(out, owner.EmailAddress) + } else if owner.DisplayName != "" { + out = append(out, owner.DisplayName) + } + } + return out +} diff --git a/internal/cmd/drive_reporting_helpers.go b/internal/cmd/drive_reporting_helpers.go new file mode 100644 index 00000000..7d97295c --- /dev/null +++ b/internal/cmd/drive_reporting_helpers.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "sort" + "strings" +) + +type driveDuSummary struct { + ID string `json:"id"` + Path string `json:"path"` + Size int64 `json:"size"` + Files int `json:"files"` + Depth int `json:"depth"` +} + +func summarizeDriveDu(items []driveTreeItem, rootID string, depthLimit int) []driveDuSummary { + type folderMeta struct { + path string + depth int + } + + parentByID := map[string]string{} + folderMetaByID := map[string]folderMeta{ + rootID: {path: ".", depth: 0}, + } + for _, it := range items { + if it.IsFolder() { + parentByID[it.ID] = it.ParentID + folderMetaByID[it.ID] = folderMeta{path: it.Path, depth: it.Depth} + } + } + + sizes := map[string]*driveDuSummary{} + getSummary := func(id string) *driveDuSummary { + if s, ok := sizes[id]; ok { + return s + } + meta := folderMetaByID[id] + s := &driveDuSummary{ + ID: id, + Path: meta.path, + Depth: meta.depth, + } + sizes[id] = s + return s + } + + for _, it := range items { + if it.IsFolder() { + continue + } + parentID := it.ParentID + for parentID != "" { + s := getSummary(parentID) + s.Size += it.Size + s.Files++ + parentID = parentByID[parentID] + } + } + + out := make([]driveDuSummary, 0, len(sizes)) + for _, s := range sizes { + if depthLimit > 0 && s.Depth > depthLimit { + continue + } + out = append(out, *s) + } + return out +} + +func sortDriveDu(items []driveDuSummary, sortBy string, order string) { + sortBy = strings.ToLower(strings.TrimSpace(sortBy)) + order = strings.ToLower(strings.TrimSpace(order)) + desc := order == "desc" + + less := func(i, j int) bool { return false } + switch sortBy { + case "path": + less = func(i, j int) bool { return items[i].Path < items[j].Path } + case "files": + less = func(i, j int) bool { return items[i].Files < items[j].Files } + default: + less = func(i, j int) bool { return items[i].Size < items[j].Size } + } + + sort.Slice(items, func(i, j int) bool { + if desc { + return less(j, i) + } + return less(i, j) + }) +} + +func sortDriveInventory(items []driveTreeItem, sortBy string, order string) { + sortBy = strings.ToLower(strings.TrimSpace(sortBy)) + order = strings.ToLower(strings.TrimSpace(order)) + desc := order == "desc" + + less := func(i, j int) bool { return false } + switch sortBy { + case "size": + less = func(i, j int) bool { return items[i].Size < items[j].Size } + case "modified": + less = func(i, j int) bool { return items[i].ModifiedTime < items[j].ModifiedTime } + default: + less = func(i, j int) bool { return items[i].Path < items[j].Path } + } + + sort.Slice(items, func(i, j int) bool { + if desc { + return less(j, i) + } + return less(i, j) + }) +} diff --git a/internal/cmd/drive_reporting_test.go b/internal/cmd/drive_reporting_test.go new file mode 100644 index 00000000..1658f3ae --- /dev/null +++ b/internal/cmd/drive_reporting_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestSanitizeDriveName(t *testing.T) { + cases := []struct { + in string + want string + }{ + {in: "", want: "_"}, + {in: ".", want: "_"}, + {in: "..", want: "_"}, + {in: "hello", want: "hello"}, + {in: "a/b", want: "a_b"}, + {in: "a\\b", want: "a_b"}, + {in: " foo ", want: "foo"}, + } + for _, tc := range cases { + if got := sanitizeDriveName(tc.in); got != tc.want { + t.Fatalf("sanitizeDriveName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestJoinDrivePath(t *testing.T) { + if got := joinDrivePath("", "file"); got != "file" { + t.Fatalf("joinDrivePath empty = %q", got) + } + if got := joinDrivePath("dir", "file"); got != "dir/file" { + t.Fatalf("joinDrivePath dir = %q", got) + } +} + +func TestSummarizeDriveDu(t *testing.T) { + items := []driveTreeItem{ + {ID: "f1", Path: "a", ParentID: "root", MimeType: driveMimeFolder, Depth: 1}, + {ID: "f2", Path: "a/b", ParentID: "f1", MimeType: driveMimeFolder, Depth: 2}, + {ID: "file1", Path: "a/file.txt", ParentID: "f1", MimeType: "text/plain", Size: 10}, + {ID: "file2", Path: "a/b/file2.txt", ParentID: "f2", MimeType: "text/plain", Size: 5}, + } + + summaries := summarizeDriveDu(items, "root", 1) + if len(summaries) == 0 { + t.Fatalf("expected summaries") + } + + var rootSize int64 + var aSize int64 + for _, s := range summaries { + if s.Path == "." { + rootSize = s.Size + } + if s.Path == "a" { + aSize = s.Size + } + } + if rootSize != 15 { + t.Fatalf("root size = %d, want 15", rootSize) + } + if aSize != 15 { + t.Fatalf("a size = %d, want 15", aSize) + } +} + +func TestExecuteDriveTreeJSON(t *testing.T) { + svc, closeSrv := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + requireQuery(t, r, "supportsAllDrives", "true") + requireQuery(t, r, "includeItemsFromAllDrives", "true") + + q := r.URL.Query().Get("q") + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(q, "'root' in parents"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + { + "id": "folder1", + "name": "Reports", + "mimeType": driveMimeFolder, + "modifiedTime": "2026-01-01T00:00:00Z", + }, + { + "id": "file1", + "name": "root.txt", + "mimeType": "text/plain", + "size": "12", + "modifiedTime": "2026-01-02T00:00:00Z", + }, + }, + }) + case strings.Contains(q, "'folder1' in parents"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + { + "id": "file2", + "name": "child.txt", + "mimeType": "text/plain", + "size": "5", + "modifiedTime": "2026-01-03T00:00:00Z", + }, + }, + }) + default: + t.Fatalf("unexpected query: %q", q) + } + })) + defer closeSrv() + stubDriveServiceForTest(t, svc) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@example.com", "drive", "tree", "--parent", "root", "--depth", "2"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Items []driveTreeItem `json:"items"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if len(parsed.Items) != 3 { + t.Fatalf("items len = %d, want 3: %#v", len(parsed.Items), parsed.Items) + } + if parsed.Items[2].Path != "Reports/child.txt" { + t.Fatalf("nested path = %q, want Reports/child.txt", parsed.Items[2].Path) + } +}