feat(drive): add read-only reporting commands (#554)
Co-authored-by: Rohan Patnaik <rohan-patnaik@users.noreply.github.com>
This commit is contained in:
parent
e8e1ac4635
commit
e9c496efd5
@ -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.
|
||||
|
||||
@ -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 <fileId> # Get file metadata
|
||||
gog drive url <fileId> # Print Drive web URL
|
||||
gog drive copy <fileId> "Copy Name"
|
||||
|
||||
# Read-only reports
|
||||
gog drive tree --parent <folderId> --depth 2
|
||||
gog drive inventory --parent <folderId> --max 500 --sort modified --order desc
|
||||
gog drive du --parent <folderId> --depth 1 --max 50
|
||||
gog --json drive inventory --parent <folderId> --max 100 | jq '.items[] | {path,size,modifiedTime}'
|
||||
|
||||
# Upload and download
|
||||
gog drive upload ./path/to/file --parent <folderId>
|
||||
gog drive upload ./path/to/file --replace <fileId> # Replace file content in-place (preserves shared link)
|
||||
|
||||
@ -245,7 +245,9 @@ Generated from `gog schema --json`.
|
||||
- [`gog drive (drv) delete (rm,del) <fileId> [flags]`](commands/gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever)
|
||||
- [`gog drive (drv) download <fileId> [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 <fileId> [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 <name> [flags]`](commands/gog-drive-mkdir.md) - Create a folder
|
||||
- [`gog drive (drv) move <fileId> [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 <fileId> <newName>`](commands/gog-drive-rename.md) - Rename a file or folder
|
||||
- [`gog drive (drv) search <query> ... [flags]`](commands/gog-drive-search.md) - Full-text search across Drive
|
||||
- [`gog drive (drv) share <fileId> [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 <fileId> <permissionId>`](commands/gog-drive-unshare.md) - Remove a permission from a file
|
||||
- [`gog drive (drv) upload <localPath> [flags]`](commands/gog-drive-upload.md) - Upload a file
|
||||
- [`gog drive (drv) url <fileId> ...`](commands/gog-drive-url.md) - Print web URLs for files
|
||||
|
||||
@ -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
|
||||
|
||||
48
docs/commands/gog-drive-du.md
Normal file
48
docs/commands/gog-drive-du.md
Normal file
@ -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`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
|
||||
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
|
||||
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
|
||||
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
|
||||
| `--max` | `int` | 50 | Max folders to return (0 = unlimited) |
|
||||
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
|
||||
| `--order` | `string` | desc | Sort order |
|
||||
| `--parent` | `string` | | Folder ID to start from (default: root) |
|
||||
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
|
||||
| `--version` | `kong.VersionFlag` | | Print version and exit |
|
||||
|
||||
## See Also
|
||||
|
||||
- [gog drive](gog-drive.md)
|
||||
- [Command index](README.md)
|
||||
48
docs/commands/gog-drive-inventory.md
Normal file
48
docs/commands/gog-drive-inventory.md
Normal file
@ -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`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
|
||||
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
|
||||
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
|
||||
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
|
||||
| `--max` | `int` | 500 | Max items to return (0 = unlimited) |
|
||||
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
|
||||
| `--order` | `string` | asc | Sort order |
|
||||
| `--parent` | `string` | | Folder ID to start from (default: root) |
|
||||
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
|
||||
| `--version` | `kong.VersionFlag` | | Print version and exit |
|
||||
|
||||
## See Also
|
||||
|
||||
- [gog drive](gog-drive.md)
|
||||
- [Command index](README.md)
|
||||
46
docs/commands/gog-drive-tree.md
Normal file
46
docs/commands/gog-drive-tree.md
Normal file
@ -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`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
|
||||
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
|
||||
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
|
||||
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
|
||||
| `--max` | `int` | 0 | Max items to return (0 = unlimited) |
|
||||
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
|
||||
| `--parent` | `string` | | Folder ID to start from (default: root) |
|
||||
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
|
||||
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
|
||||
| `--version` | `kong.VersionFlag` | | Print version and exit |
|
||||
|
||||
## See Also
|
||||
|
||||
- [gog drive](gog-drive.md)
|
||||
- [Command index](README.md)
|
||||
@ -21,7 +21,9 @@ gog drive (drv) <command> [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) <command> [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
|
||||
|
||||
@ -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
|
||||
|
||||
413
internal/cmd/drive_reporting.go
Normal file
413
internal/cmd/drive_reporting.go
Normal file
@ -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
|
||||
}
|
||||
115
internal/cmd/drive_reporting_helpers.go
Normal file
115
internal/cmd/drive_reporting_helpers.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
139
internal/cmd/drive_reporting_test.go
Normal file
139
internal/cmd/drive_reporting_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user