refactor(cmd): split drive command modules
This commit is contained in:
parent
ad59efba58
commit
917e4b98b4
@ -2,13 +2,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@ -58,15 +53,6 @@ const (
|
||||
extMD = ".md"
|
||||
extHTML = ".html"
|
||||
formatAuto = literalAuto
|
||||
driveShareToAnyone = "anyone"
|
||||
driveShareToUser = "user"
|
||||
driveShareToDomain = "domain"
|
||||
|
||||
// Drive sharing permission roles matching the Google Drive API roles.
|
||||
// "commenter" allows view + comment access without edit rights.
|
||||
drivePermRoleReader = "reader"
|
||||
drivePermRoleWriter = "writer"
|
||||
drivePermRoleCommenter = "commenter"
|
||||
)
|
||||
|
||||
type DriveCmd struct {
|
||||
@ -92,94 +78,6 @@ type DriveCmd struct {
|
||||
Raw DriveRawCmd `cmd:"" name:"raw" help:"Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption)"`
|
||||
}
|
||||
|
||||
// driveRawSensitiveFields is the set of top-level File fields redacted from
|
||||
// `gog drive raw` output when the user did not name them via --fields. See
|
||||
// docs/raw-audit.md for the rationale per field.
|
||||
var driveRawSensitiveFields = []string{
|
||||
"thumbnailLink",
|
||||
"webContentLink",
|
||||
"exportLinks",
|
||||
"resourceKey",
|
||||
"appProperties",
|
||||
"properties",
|
||||
}
|
||||
|
||||
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
|
||||
// default to expose the entire File resource. When --fields is absent the
|
||||
// command redacts a small set of capability/token-shaped fields (see
|
||||
// driveRawSensitiveFields); when --fields is explicitly set the response is
|
||||
// returned verbatim, honoring exactly what the user asked for. This means
|
||||
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
|
||||
// requested.
|
||||
//
|
||||
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
|
||||
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
|
||||
type DriveRawCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
|
||||
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
|
||||
}
|
||||
|
||||
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userSetFields := strings.TrimSpace(c.Fields) != ""
|
||||
mask := "*"
|
||||
if userSetFields {
|
||||
mask = c.Fields
|
||||
}
|
||||
|
||||
f, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields(gapi.Field(mask)).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err = requireRawResponse(f, "file not found")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Round-trip through JSON so we can redact by key when needed.
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal drive file: %w", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("unmarshal drive file: %w", err)
|
||||
}
|
||||
|
||||
// Redact only when the user did not explicitly request fields.
|
||||
if !userSetFields {
|
||||
for _, key := range driveRawSensitiveFields {
|
||||
delete(m, key)
|
||||
}
|
||||
// contentHints.thumbnail.image is the one nested leak.
|
||||
if hints, ok := m["contentHints"].(map[string]any); ok {
|
||||
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
|
||||
delete(thumb, "image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, m, c.Pretty)
|
||||
}
|
||||
|
||||
type DriveLsCmd struct {
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"`
|
||||
Page string `name:"page" aliases:"cursor" help:"Page token"`
|
||||
@ -254,91 +152,6 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
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|md (default: inferred)"`
|
||||
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
|
||||
}
|
||||
|
||||
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
if tab := strings.TrimSpace(c.Tab); tab != "" {
|
||||
if f := c.Format; f != "" && f != formatAuto {
|
||||
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
|
||||
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
|
||||
}
|
||||
}
|
||||
return runDocsTabExport(ctx, flags, tabExportParams{
|
||||
DocID: fileID,
|
||||
OutFlag: c.Output.Path,
|
||||
Format: c.Format,
|
||||
TabQuery: tab,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
|
||||
return formatErr
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
|
||||
return fileFormatErr
|
||||
}
|
||||
|
||||
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"path": downloadedPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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"`
|
||||
@ -351,18 +164,6 @@ func (c *DriveCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}, 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 (create) or rename target (replace)"`
|
||||
Parent string `name:"parent" help:"Destination folder ID (create only)"`
|
||||
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
|
||||
MimeType string `name:"mime-type" help:"Override MIME type inference"`
|
||||
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
|
||||
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
|
||||
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
|
||||
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
|
||||
}
|
||||
|
||||
type DriveMkdirCmd struct {
|
||||
Name string `arg:"" name:"name" help:"Folder name"`
|
||||
Parent string `name:"parent" help:"Parent folder ID"`
|
||||
@ -569,312 +370,6 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveShareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
To string `name:"to" help:"Share target: anyone|user|domain"`
|
||||
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
|
||||
Email string `name:"email" help:"User email (for --to=user)"`
|
||||
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
|
||||
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
|
||||
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
|
||||
}
|
||||
|
||||
type driveShareTarget struct {
|
||||
to string
|
||||
email string
|
||||
domain string
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
target, err := c.normalizeTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeDrivePermissionRole(c.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.to == driveShareToAnyone {
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perm := target.permission(role, c.Discoverable)
|
||||
|
||||
created, err := svc.Permissions.Create(fileID, perm).
|
||||
SupportsAllDrives(true).
|
||||
SendNotificationEmail(false).
|
||||
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
|
||||
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(ctx, 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
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
|
||||
to := strings.TrimSpace(c.To)
|
||||
email := strings.TrimSpace(c.Email)
|
||||
domain := strings.TrimSpace(c.Domain)
|
||||
|
||||
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
|
||||
// New UX: prefer explicit --to + matching parameter.
|
||||
if to == "" {
|
||||
switch {
|
||||
case c.Anyone && email == "" && domain == "":
|
||||
to = driveShareToAnyone
|
||||
case !c.Anyone && email != "" && domain == "":
|
||||
to = driveShareToUser
|
||||
case !c.Anyone && email == "" && domain != "":
|
||||
to = driveShareToDomain
|
||||
case !c.Anyone && email == "" && domain == "":
|
||||
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
|
||||
default:
|
||||
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
|
||||
}
|
||||
}
|
||||
|
||||
switch to {
|
||||
case driveShareToAnyone:
|
||||
if email != "" || domain != "" {
|
||||
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
|
||||
}
|
||||
case driveShareToUser:
|
||||
if email == "" {
|
||||
return driveShareTarget{}, usage("missing --email for --to=user")
|
||||
}
|
||||
if domain != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
|
||||
}
|
||||
if c.Discoverable {
|
||||
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
|
||||
}
|
||||
case driveShareToDomain:
|
||||
if domain == "" {
|
||||
return driveShareTarget{}, usage("missing --domain for --to=domain")
|
||||
}
|
||||
if email != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
|
||||
}
|
||||
default:
|
||||
// Should be guarded by enum, but keep a friendly message for future changes.
|
||||
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
|
||||
}
|
||||
|
||||
return driveShareTarget{to: to, email: email, domain: domain}, nil
|
||||
}
|
||||
|
||||
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
|
||||
perm := &drive.Permission{Role: role}
|
||||
switch target.to {
|
||||
case driveShareToAnyone:
|
||||
perm.Type = "anyone"
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
case driveShareToDomain:
|
||||
perm.Type = "domain"
|
||||
perm.Domain = target.domain
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
default:
|
||||
perm.Type = "user"
|
||||
perm.EmailAddress = target.email
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func normalizeDrivePermissionRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return drivePermRoleReader, nil
|
||||
}
|
||||
switch role {
|
||||
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
|
||||
return role, nil
|
||||
default:
|
||||
return "", usage("invalid --role (expected reader|writer|commenter)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return writeResult(ctx, u,
|
||||
kv("removed", true),
|
||||
kv("fileId", fileID),
|
||||
kv("permissionId", permissionID),
|
||||
)
|
||||
}
|
||||
|
||||
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" aliases:"cursor" 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, domain)").
|
||||
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(ctx, 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 == "" && p.Domain != "" {
|
||||
email = p.Domain
|
||||
}
|
||||
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(ctx, 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)
|
||||
@ -985,119 +480,6 @@ func formatDriveSize(bytes int64) string {
|
||||
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.")
|
||||
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalizedFormat == formatAuto {
|
||||
normalizedFormat = ""
|
||||
}
|
||||
|
||||
if !isGoogleDoc && normalizedFormat != "" {
|
||||
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
|
||||
return "", 0, fileFormatErr
|
||||
}
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
outPath string
|
||||
err error
|
||||
)
|
||||
|
||||
if isGoogleDoc {
|
||||
var exportMimeType string
|
||||
if normalizedFormat == "" {
|
||||
exportMimeType = driveExportMimeType(meta.MimeType)
|
||||
} else {
|
||||
var mimeErr error
|
||||
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
|
||||
if mimeErr != nil {
|
||||
return "", 0, mimeErr
|
||||
}
|
||||
}
|
||||
if isStdoutPath(destPath) {
|
||||
outPath = stdoutPath
|
||||
} else {
|
||||
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)))
|
||||
}
|
||||
|
||||
if isStdoutPath(outPath) {
|
||||
n, copyErr := io.Copy(os.Stdout, resp.Body)
|
||||
return stdoutPath, n, copyErr
|
||||
}
|
||||
|
||||
f, outPath, err := createUserOutputFile(outPath)
|
||||
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
|
||||
}
|
||||
|
||||
func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives bool, driveID string) *drive.FilesListCall {
|
||||
// SupportsAllDrives must be set for shared drive file IDs to behave correctly.
|
||||
call = call.SupportsAllDrives(true).IncludeItemsFromAllDrives(allDrives)
|
||||
@ -1111,216 +493,3 @@ func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives boo
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatFlag(format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
return nil
|
||||
}
|
||||
switch format {
|
||||
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
|
||||
return nil
|
||||
default:
|
||||
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
|
||||
if meta == nil {
|
||||
return errors.New("missing file metadata")
|
||||
}
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
if isGoogleDoc {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(format) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
|
||||
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 == "" || format == formatAuto {
|
||||
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
|
||||
case "md":
|
||||
return mimeTextMarkdown, nil
|
||||
case "html":
|
||||
return mimeHTML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", 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
|
||||
case mimeTextMarkdown:
|
||||
return extMD
|
||||
case mimeHTML:
|
||||
return extHTML
|
||||
default:
|
||||
return extPDF
|
||||
}
|
||||
}
|
||||
|
||||
// googleConvertMimeType returns the Google-native MIME type for convertible
|
||||
// Office/text formats. The boolean indicates whether the extension is supported.
|
||||
func googleConvertMimeType(path string) (string, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extDocx, ".doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case extXlsx, ".xls", extCSV:
|
||||
return driveMimeGoogleSheet, true
|
||||
case extPptx, ".ppt":
|
||||
return driveMimeGoogleSlides, true
|
||||
case extTXT, ".html", extMD:
|
||||
return driveMimeGoogleDoc, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func googleConvertTargetMimeType(target string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||
case "doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case "sheet":
|
||||
return driveMimeGoogleSheet, true
|
||||
case "slides":
|
||||
return driveMimeGoogleSlides, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target != "" {
|
||||
mimeType, ok := googleConvertTargetMimeType(target)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
if !auto {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
mimeType, ok := googleConvertMimeType(path)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
|
||||
// stripOfficeExt removes common Office extensions from a filename so
|
||||
// the resulting Google Doc/Sheet/Slides has a clean name.
|
||||
func stripOfficeExt(name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
|
||||
return strings.TrimSuffix(name, filepath.Ext(name))
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
310
internal/cmd/drive_download.go
Normal file
310
internal/cmd/drive_download.go
Normal file
@ -0,0 +1,310 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
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|md (default: inferred)"`
|
||||
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
|
||||
}
|
||||
|
||||
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
if tab := strings.TrimSpace(c.Tab); tab != "" {
|
||||
if f := c.Format; f != "" && f != formatAuto {
|
||||
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
|
||||
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
|
||||
}
|
||||
}
|
||||
return runDocsTabExport(ctx, flags, tabExportParams{
|
||||
DocID: fileID,
|
||||
OutFlag: c.Output.Path,
|
||||
Format: c.Format,
|
||||
TabQuery: tab,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
|
||||
return formatErr
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
|
||||
return fileFormatErr
|
||||
}
|
||||
|
||||
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"path": downloadedPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Out().Printf("path\t%s", downloadedPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
return nil
|
||||
}
|
||||
|
||||
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.")
|
||||
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalizedFormat == formatAuto {
|
||||
normalizedFormat = ""
|
||||
}
|
||||
|
||||
if !isGoogleDoc && normalizedFormat != "" {
|
||||
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
|
||||
return "", 0, fileFormatErr
|
||||
}
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
outPath string
|
||||
err error
|
||||
)
|
||||
|
||||
if isGoogleDoc {
|
||||
var exportMimeType string
|
||||
if normalizedFormat == "" {
|
||||
exportMimeType = driveExportMimeType(meta.MimeType)
|
||||
} else {
|
||||
var mimeErr error
|
||||
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
|
||||
if mimeErr != nil {
|
||||
return "", 0, mimeErr
|
||||
}
|
||||
}
|
||||
if isStdoutPath(destPath) {
|
||||
outPath = stdoutPath
|
||||
} else {
|
||||
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)))
|
||||
}
|
||||
|
||||
if isStdoutPath(outPath) {
|
||||
n, copyErr := io.Copy(os.Stdout, resp.Body)
|
||||
return stdoutPath, n, copyErr
|
||||
}
|
||||
|
||||
f, outPath, err := createUserOutputFile(outPath)
|
||||
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
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatFlag(format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
return nil
|
||||
}
|
||||
switch format {
|
||||
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
|
||||
return nil
|
||||
default:
|
||||
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
|
||||
if meta == nil {
|
||||
return errors.New("missing file metadata")
|
||||
}
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
if isGoogleDoc {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(format) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
|
||||
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 == "" || format == formatAuto {
|
||||
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
|
||||
case "md":
|
||||
return mimeTextMarkdown, nil
|
||||
case "html":
|
||||
return mimeHTML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", 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
|
||||
case mimeTextMarkdown:
|
||||
return extMD
|
||||
case mimeHTML:
|
||||
return extHTML
|
||||
default:
|
||||
return extPDF
|
||||
}
|
||||
}
|
||||
95
internal/cmd/drive_raw.go
Normal file
95
internal/cmd/drive_raw.go
Normal file
@ -0,0 +1,95 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gapi "google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
// driveRawSensitiveFields is the set of top-level File fields redacted from
|
||||
// `gog drive raw` output when the user did not name them via --fields. See
|
||||
// docs/raw-audit.md for the rationale per field.
|
||||
var driveRawSensitiveFields = []string{
|
||||
"thumbnailLink",
|
||||
"webContentLink",
|
||||
"exportLinks",
|
||||
"resourceKey",
|
||||
"appProperties",
|
||||
"properties",
|
||||
}
|
||||
|
||||
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
|
||||
// default to expose the entire File resource. When --fields is absent the
|
||||
// command redacts a small set of capability/token-shaped fields (see
|
||||
// driveRawSensitiveFields); when --fields is explicitly set the response is
|
||||
// returned verbatim, honoring exactly what the user asked for. This means
|
||||
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
|
||||
// requested.
|
||||
//
|
||||
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
|
||||
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
|
||||
type DriveRawCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
|
||||
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
|
||||
}
|
||||
|
||||
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userSetFields := strings.TrimSpace(c.Fields) != ""
|
||||
mask := "*"
|
||||
if userSetFields {
|
||||
mask = c.Fields
|
||||
}
|
||||
|
||||
f, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields(gapi.Field(mask)).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err = requireRawResponse(f, "file not found")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal drive file: %w", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("unmarshal drive file: %w", err)
|
||||
}
|
||||
|
||||
if !userSetFields {
|
||||
for _, key := range driveRawSensitiveFields {
|
||||
delete(m, key)
|
||||
}
|
||||
if hints, ok := m["contentHints"].(map[string]any); ok {
|
||||
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
|
||||
delete(thumb, "image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, m, c.Pretty)
|
||||
}
|
||||
341
internal/cmd/drive_sharing.go
Normal file
341
internal/cmd/drive_sharing.go
Normal file
@ -0,0 +1,341 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
driveShareToAnyone = "anyone"
|
||||
driveShareToUser = "user"
|
||||
driveShareToDomain = "domain"
|
||||
|
||||
// Drive sharing permission roles matching the Google Drive API roles.
|
||||
// "commenter" allows view + comment access without edit rights.
|
||||
drivePermRoleReader = "reader"
|
||||
drivePermRoleWriter = "writer"
|
||||
drivePermRoleCommenter = "commenter"
|
||||
)
|
||||
|
||||
type DriveShareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
To string `name:"to" help:"Share target: anyone|user|domain"`
|
||||
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
|
||||
Email string `name:"email" help:"User email (for --to=user)"`
|
||||
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
|
||||
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
|
||||
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
|
||||
}
|
||||
|
||||
type driveShareTarget struct {
|
||||
to string
|
||||
email string
|
||||
domain string
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
target, err := c.normalizeTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeDrivePermissionRole(c.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.to == driveShareToAnyone {
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perm := target.permission(role, c.Discoverable)
|
||||
|
||||
created, err := svc.Permissions.Create(fileID, perm).
|
||||
SupportsAllDrives(true).
|
||||
SendNotificationEmail(false).
|
||||
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
|
||||
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(ctx, 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
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
|
||||
to := strings.TrimSpace(c.To)
|
||||
email := strings.TrimSpace(c.Email)
|
||||
domain := strings.TrimSpace(c.Domain)
|
||||
|
||||
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
|
||||
// New UX: prefer explicit --to + matching parameter.
|
||||
if to == "" {
|
||||
switch {
|
||||
case c.Anyone && email == "" && domain == "":
|
||||
to = driveShareToAnyone
|
||||
case !c.Anyone && email != "" && domain == "":
|
||||
to = driveShareToUser
|
||||
case !c.Anyone && email == "" && domain != "":
|
||||
to = driveShareToDomain
|
||||
case !c.Anyone && email == "" && domain == "":
|
||||
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
|
||||
default:
|
||||
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
|
||||
}
|
||||
}
|
||||
|
||||
switch to {
|
||||
case driveShareToAnyone:
|
||||
if email != "" || domain != "" {
|
||||
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
|
||||
}
|
||||
case driveShareToUser:
|
||||
if email == "" {
|
||||
return driveShareTarget{}, usage("missing --email for --to=user")
|
||||
}
|
||||
if domain != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
|
||||
}
|
||||
if c.Discoverable {
|
||||
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
|
||||
}
|
||||
case driveShareToDomain:
|
||||
if domain == "" {
|
||||
return driveShareTarget{}, usage("missing --domain for --to=domain")
|
||||
}
|
||||
if email != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
|
||||
}
|
||||
default:
|
||||
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
|
||||
}
|
||||
|
||||
return driveShareTarget{to: to, email: email, domain: domain}, nil
|
||||
}
|
||||
|
||||
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
|
||||
perm := &drive.Permission{Role: role}
|
||||
switch target.to {
|
||||
case driveShareToAnyone:
|
||||
perm.Type = "anyone"
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
case driveShareToDomain:
|
||||
perm.Type = "domain"
|
||||
perm.Domain = target.domain
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
default:
|
||||
perm.Type = "user"
|
||||
perm.EmailAddress = target.email
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func normalizeDrivePermissionRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return drivePermRoleReader, nil
|
||||
}
|
||||
switch role {
|
||||
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
|
||||
return role, nil
|
||||
default:
|
||||
return "", usage("invalid --role (expected reader|writer|commenter)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return writeResult(ctx, u,
|
||||
kv("removed", true),
|
||||
kv("fileId", fileID),
|
||||
kv("permissionId", permissionID),
|
||||
)
|
||||
}
|
||||
|
||||
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" aliases:"cursor" 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, domain)").
|
||||
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(ctx, 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 == "" && p.Domain != "" {
|
||||
email = p.Domain
|
||||
}
|
||||
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(ctx, os.Stdout, map[string]any{"urls": urls})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -17,6 +17,18 @@ import (
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type DriveUploadCmd struct {
|
||||
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
|
||||
Name string `name:"name" help:"Override filename (create) or rename target (replace)"`
|
||||
Parent string `name:"parent" help:"Destination folder ID (create only)"`
|
||||
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
|
||||
MimeType string `name:"mime-type" help:"Override MIME type inference"`
|
||||
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
|
||||
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
|
||||
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
|
||||
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
|
||||
}
|
||||
|
||||
type driveUploadOptions struct {
|
||||
localPath string
|
||||
fileName string
|
||||
@ -30,6 +42,113 @@ type driveUploadOptions struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// googleConvertMimeType returns the Google-native MIME type for convertible
|
||||
// Office/text formats. The boolean indicates whether the extension is supported.
|
||||
func googleConvertMimeType(path string) (string, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extDocx, ".doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case extXlsx, ".xls", extCSV:
|
||||
return driveMimeGoogleSheet, true
|
||||
case extPptx, ".ppt":
|
||||
return driveMimeGoogleSlides, true
|
||||
case extTXT, ".html", extMD:
|
||||
return driveMimeGoogleDoc, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func googleConvertTargetMimeType(target string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||
case "doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case "sheet":
|
||||
return driveMimeGoogleSheet, true
|
||||
case "slides":
|
||||
return driveMimeGoogleSlides, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target != "" {
|
||||
mimeType, ok := googleConvertTargetMimeType(target)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
if !auto {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
mimeType, ok := googleConvertMimeType(path)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
|
||||
// stripOfficeExt removes common Office extensions from a filename so
|
||||
// the resulting Google Doc/Sheet/Slides has a clean name.
|
||||
func stripOfficeExt(name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
|
||||
return strings.TrimSuffix(name, filepath.Ext(name))
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
opts, err := prepareDriveUpload(c)
|
||||
if err != nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user