gogcli/internal/cmd/drive.go
sheeek 174de114fc feat(drive): add 'drives' subcommand to list shared drives
Adds gog drive drives command that calls the Google Drive API drives.list
endpoint to enumerate all shared drives (Team Drives) the user has access to.

Supports:
- --max/--limit for pagination
- --page for continuation
- --query/-q for filtering shared drives
- --json for scripted output
- Standard table output with ID, NAME, CREATED columns

API Reference: https://developers.google.com/drive/api/v3/reference/drives/list
2026-01-15 17:11:19 +00:00

1065 lines
26 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
var newDriveService = googleapi.NewDrive
const (
driveMimeGoogleDoc = "application/vnd.google-apps.document"
driveMimeGoogleSheet = "application/vnd.google-apps.spreadsheet"
driveMimeGoogleSlides = "application/vnd.google-apps.presentation"
driveMimeGoogleDrawing = "application/vnd.google-apps.drawing"
mimePDF = "application/pdf"
mimeCSV = "text/csv"
mimeDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
mimeXlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
mimePptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
mimePNG = "image/png"
mimeTextPlain = "text/plain"
extPDF = ".pdf"
extCSV = ".csv"
extXlsx = ".xlsx"
extDocx = ".docx"
extPptx = ".pptx"
extPNG = ".png"
extTXT = ".txt"
)
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"`
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"`
Upload DriveUploadCmd `cmd:"" name:"upload" help:"Upload a file"`
Mkdir DriveMkdirCmd `cmd:"" name:"mkdir" help:"Create a folder"`
Delete DriveDeleteCmd `cmd:"" name:"delete" help:"Delete a file (moves to trash)" aliases:"rm,del"`
Move DriveMoveCmd `cmd:"" name:"move" help:"Move a file to a different folder"`
Rename DriveRenameCmd `cmd:"" name:"rename" help:"Rename a file or folder"`
Share DriveShareCmd `cmd:"" name:"share" help:"Share a file or folder"`
Unshare DriveUnshareCmd `cmd:"" name:"unshare" help:"Remove a permission from a file"`
Permissions DrivePermissionsCmd `cmd:"" name:"permissions" help:"List permissions on a file"`
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)"`
}
type DriveLsCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"`
Page string `name:"page" help:"Page token"`
Query string `name:"query" help:"Drive query filter"`
Parent string `name:"parent" help:"Folder ID to list (default: root)"`
}
func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
folderID := strings.TrimSpace(c.Parent)
if folderID == "" {
folderID = "root"
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
q := buildDriveListQuery(folderID, c.Query)
resp, err := svc.Files.List().
Q(q).
PageSize(c.Max).
PageToken(c.Page).
OrderBy("modifiedTime desc").
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"files": resp.Files,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Files) == 0 {
u.Err().Println("No files")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
driveType(f.MimeType),
formatDriveSize(f.Size),
formatDateTime(f.ModifiedTime),
)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type DriveSearchCmd struct {
Query []string `arg:"" name:"query" help:"Search query"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"`
Page string `name:"page" help:"Page token"`
}
func (c *DriveSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.TrimSpace(strings.Join(c.Query, " "))
if query == "" {
return usage("missing query")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
resp, err := svc.Files.List().
Q(buildDriveSearchQuery(query)).
PageSize(c.Max).
PageToken(c.Page).
OrderBy("modifiedTime desc").
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"files": resp.Files,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Files) == 0 {
u.Err().Println("No results")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
driveType(f.MimeType),
formatDriveSize(f.Size),
formatDateTime(f.ModifiedTime),
)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type DriveGetCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
}
func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
f, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f})
}
u.Out().Printf("id\t%s", f.Id)
u.Out().Printf("name\t%s", f.Name)
u.Out().Printf("type\t%s", f.MimeType)
u.Out().Printf("size\t%s", formatDriveSize(f.Size))
u.Out().Printf("created\t%s", f.CreatedTime)
u.Out().Printf("modified\t%s", f.ModifiedTime)
if f.Description != "" {
u.Out().Printf("description\t%s", f.Description)
}
u.Out().Printf("starred\t%t", f.Starred)
if f.WebViewLink != "" {
u.Out().Printf("link\t%s", f.WebViewLink)
}
return nil
}
type DriveDownloadCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx (default: auto)"`
}
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType").
Context(ctx).
Do()
if err != nil {
return err
}
if meta.Name == "" {
return errors.New("file has no name")
}
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
if err != nil {
return err
}
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"path": downloadedPath,
"size": size,
})
}
u.Out().Printf("path\t%s", downloadedPath)
u.Out().Printf("size\t%s", formatDriveSize(size))
return nil
}
type DriveCopyCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Name string `arg:"" name:"name" help:"New file name"`
Parent string `name:"parent" help:"Destination folder ID"`
}
func (c *DriveCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
return copyViaDrive(ctx, flags, copyViaDriveOptions{
ArgName: "fileId",
}, c.FileID, c.Name, c.Parent)
}
type DriveUploadCmd struct {
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
Name string `name:"name" help:"Override filename"`
Parent string `name:"parent" help:"Destination folder ID"`
}
func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
localPath := strings.TrimSpace(c.LocalPath)
if localPath == "" {
return usage("empty localPath")
}
localPath, err = config.ExpandPath(localPath)
if err != nil {
return err
}
f, err := os.Open(localPath) //nolint:gosec // user-provided path
if err != nil {
return err
}
defer f.Close()
fileName := strings.TrimSpace(c.Name)
if fileName == "" {
fileName = filepath.Base(localPath)
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
meta := &drive.File{Name: fileName}
parent := strings.TrimSpace(c.Parent)
if parent != "" {
meta.Parents = []string{parent}
}
mimeType := guessMimeType(localPath)
created, err := svc.Files.Create(meta).
SupportsAllDrives(true).
Media(f, gapi.ContentType(mimeType)).
Fields("id, name, mimeType, size, webViewLink").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("name\t%s", created.Name)
if created.WebViewLink != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
}
type DriveMkdirCmd struct {
Name string `arg:"" name:"name" help:"Folder name"`
Parent string `name:"parent" help:"Parent folder ID"`
}
func (c *DriveMkdirCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
name := strings.TrimSpace(c.Name)
if name == "" {
return usage("empty name")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
f := &drive.File{
Name: name,
MimeType: "application/vnd.google-apps.folder",
}
if strings.TrimSpace(c.Parent) != "" {
f.Parents = []string{strings.TrimSpace(c.Parent)}
}
created, err := svc.Files.Create(f).
SupportsAllDrives(true).
Fields("id, name, webViewLink").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"folder": created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("name\t%s", created.Name)
if created.WebViewLink != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
}
type DriveDeleteCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
}
func (c *DriveDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("delete drive file %s", fileID)); confirmErr != nil {
return confirmErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": true,
"id": fileID,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("id\t%s", fileID)
return nil
}
type DriveMoveCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Parent string `name:"parent" help:"New parent folder ID (required)"`
}
func (c *DriveMoveCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
parent := strings.TrimSpace(c.Parent)
if parent == "" {
return usage("missing --parent")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, parents").
Context(ctx).
Do()
if err != nil {
return err
}
call := svc.Files.Update(fileID, &drive.File{}).
SupportsAllDrives(true).
AddParents(parent).
Fields("id, name, parents, webViewLink")
if len(meta.Parents) > 0 {
call = call.RemoveParents(strings.Join(meta.Parents, ","))
}
updated, err := call.Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("name\t%s", updated.Name)
return nil
}
type DriveRenameCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
NewName string `arg:"" name:"newName" help:"New name"`
}
func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
newName := strings.TrimSpace(c.NewName)
if fileID == "" {
return usage("empty fileId")
}
if newName == "" {
return usage("empty newName")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
updated, err := svc.Files.Update(fileID, &drive.File{Name: newName}).
SupportsAllDrives(true).
Fields("id, name").
Context(ctx).
Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("name\t%s", updated.Name)
return nil
}
type DriveShareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Anyone bool `name:"anyone" help:"Make publicly accessible"`
Email string `name:"email" help:"Share with specific user"`
Role string `name:"role" help:"Permission: reader|writer" default:"reader"`
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
}
func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
if !c.Anyone && strings.TrimSpace(c.Email) == "" {
return usage("must specify --anyone or --email")
}
role := strings.TrimSpace(c.Role)
if role == "" {
role = "reader"
}
if role != "reader" && role != "writer" {
return usage("invalid --role (expected reader|writer)")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
perm := &drive.Permission{Role: role}
if c.Anyone {
perm.Type = "anyone"
perm.AllowFileDiscovery = c.Discoverable
} else {
perm.Type = "user"
perm.EmailAddress = strings.TrimSpace(c.Email)
}
created, err := svc.Permissions.Create(fileID, perm).
SupportsAllDrives(true).
SendNotificationEmail(false).
Fields("id, type, role, emailAddress").
Context(ctx).
Do()
if err != nil {
return err
}
link, err := driveWebLink(ctx, svc, fileID)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"link": link,
"permissionId": created.Id,
"permission": created,
})
}
u.Out().Printf("link\t%s", link)
u.Out().Printf("permission_id\t%s", created.Id)
return nil
}
type DriveUnshareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
PermissionID string `arg:"" name:"permissionId" help:"Permission ID"`
}
func (c *DriveUnshareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
permissionID := strings.TrimSpace(c.PermissionID)
if fileID == "" {
return usage("empty fileId")
}
if permissionID == "" {
return usage("empty permissionId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); confirmErr != nil {
return confirmErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"removed": true,
"fileId": fileID,
"permissionId": permissionID,
})
}
u.Out().Printf("removed\ttrue")
u.Out().Printf("file_id\t%s", fileID)
u.Out().Printf("permission_id\t%s", permissionID)
return nil
}
type DrivePermissionsCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
}
func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
call := svc.Permissions.List(fileID).
SupportsAllDrives(true).
Fields("nextPageToken, permissions(id, type, role, emailAddress)").
Context(ctx)
if c.Max > 0 {
call = call.PageSize(c.Max)
}
if strings.TrimSpace(c.Page) != "" {
call = call.PageToken(c.Page)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"fileId": fileID,
"permissions": resp.Permissions,
"permissionCount": len(resp.Permissions),
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Permissions) == 0 {
u.Err().Println("No permissions")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
for _, p := range resp.Permissions {
email := p.EmailAddress
if email == "" {
email = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type DriveURLCmd struct {
FileIDs []string `arg:"" name:"fileId" help:"File IDs"`
}
func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
// collected below
} else {
u.Out().Printf("%s\t%s", id, link)
}
}
if outfmt.IsJSON(ctx) {
urls := make([]map[string]string, 0, len(c.FileIDs))
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
urls = append(urls, map[string]string{"id": id, "url": link})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls})
}
return nil
}
func buildDriveListQuery(folderID string, userQuery string) string {
q := strings.TrimSpace(userQuery)
parent := fmt.Sprintf("'%s' in parents", folderID)
if q != "" {
q = q + " and " + parent
} else {
q = parent
}
if !strings.Contains(q, "trashed") {
q += " and trashed = false"
}
return q
}
func buildDriveSearchQuery(text string) string {
q := fmt.Sprintf("fullText contains '%s'", escapeDriveQueryString(text))
return q + " and trashed = false"
}
func escapeDriveQueryString(s string) string {
// Escape backslashes first, then single quotes
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "'", "\\'")
return s
}
func driveType(mimeType string) string {
if mimeType == "application/vnd.google-apps.folder" {
return "folder"
}
return strFile
}
func formatDateTime(iso string) string {
if iso == "" {
return "-"
}
if len(iso) >= 16 {
return strings.ReplaceAll(iso[:16], "T", " ")
}
return iso
}
func formatDriveSize(bytes int64) string {
if bytes <= 0 {
return "-"
}
const unit = 1024.0
b := float64(bytes)
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for b >= unit && i < len(units)-1 {
b /= unit
i++
}
if i == 0 {
return fmt.Sprintf("%d B", bytes)
}
return fmt.Sprintf("%.1f %s", b, units[i])
}
func guessMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case extPDF:
return mimePDF
case ".doc":
return "application/msword"
case extDocx:
return mimeDocx
case ".xls":
return "application/vnd.ms-excel"
case extXlsx:
return mimeXlsx
case ".ppt":
return "application/vnd.ms-powerpoint"
case extPptx:
return mimePptx
case extPNG:
return mimePNG
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case extTXT:
return mimeTextPlain
case ".html":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".zip":
return "application/zip"
case ".csv":
return "text/csv"
case ".md":
return "text/markdown"
default:
return "application/octet-stream"
}
}
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
var (
resp *http.Response
outPath string
err error
)
if isGoogleDoc {
var exportMimeType string
if strings.TrimSpace(format) == "" {
exportMimeType = driveExportMimeType(meta.MimeType)
} else {
var mimeErr error
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, format)
if mimeErr != nil {
return "", 0, mimeErr
}
}
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
} else {
outPath = destPath
resp, err = driveDownload(ctx, svc, meta.Id)
}
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
f, err := os.Create(outPath) //nolint:gosec // user-provided path
if err != nil {
return "", 0, err
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return "", 0, err
}
return outPath, n, nil
}
var driveDownload = func(ctx context.Context, svc *drive.Service, fileID string) (*http.Response, error) {
return svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
}
var driveExportDownload = func(ctx context.Context, svc *drive.Service, fileID string, mimeType string) (*http.Response, error) {
return svc.Files.Export(fileID, mimeType).Context(ctx).Download()
}
func replaceExt(path string, ext string) string {
base := strings.TrimSuffix(path, filepath.Ext(path))
return base + ext
}
func driveExportMimeType(googleMimeType string) string {
switch googleMimeType {
case driveMimeGoogleDoc:
return mimePDF
case driveMimeGoogleSheet:
return mimeCSV
case driveMimeGoogleSlides:
return mimePDF
case driveMimeGoogleDrawing:
return mimePNG
default:
return mimePDF
}
}
func driveExportMimeTypeForFormat(googleMimeType string, format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
return driveExportMimeType(googleMimeType), nil
}
switch googleMimeType {
case driveMimeGoogleDoc:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "docx":
return mimeDocx, nil
case "txt":
return mimeTextPlain, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt)", format)
}
case driveMimeGoogleSheet:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "csv":
return mimeCSV, nil
case "xlsx":
return mimeXlsx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Sheet (use pdf|csv|xlsx)", format)
}
case driveMimeGoogleSlides:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "pptx":
return mimePptx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Slides (use pdf|pptx)", format)
}
case driveMimeGoogleDrawing:
switch format {
case "png":
return mimePNG, nil
case defaultExportFormat:
return mimePDF, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Drawing (use png|pdf)", format)
}
default:
if format == defaultExportFormat {
return mimePDF, nil
}
return "", fmt.Errorf("invalid --format %q for file type %q (use pdf)", format, googleMimeType)
}
}
func driveExportExtension(mimeType string) string {
switch mimeType {
case mimePDF:
return extPDF
case mimeCSV:
return extCSV
case mimeXlsx:
return extXlsx
case mimeDocx:
return extDocx
case mimePptx:
return extPptx
case mimePNG:
return extPNG
case mimeTextPlain:
return extTXT
default:
return extPDF
}
}
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
if err != nil {
return "", err
}
if f.WebViewLink != "" {
return f.WebViewLink, nil
}
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
}