gogcli/internal/cmd/drive_reporting.go
Peter Steinberger e9c496efd5
feat(drive): add read-only reporting commands (#554)
Co-authored-by: Rohan Patnaik <rohan-patnaik@users.noreply.github.com>
2026-05-05 05:36:55 +01:00

414 lines
9.8 KiB
Go

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
}