From 917e4b98b491190291038fb0aa0df31ea42f8efe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 08:49:02 +0100 Subject: [PATCH] refactor(cmd): split drive command modules --- internal/cmd/drive.go | 831 --------------------------------- internal/cmd/drive_download.go | 310 ++++++++++++ internal/cmd/drive_raw.go | 95 ++++ internal/cmd/drive_sharing.go | 341 ++++++++++++++ internal/cmd/drive_upload.go | 119 +++++ 5 files changed, 865 insertions(+), 831 deletions(-) create mode 100644 internal/cmd/drive_download.go create mode 100644 internal/cmd/drive_raw.go create mode 100644 internal/cmd/drive_sharing.go diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 5c63b7b..80b94c3 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -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 -} diff --git a/internal/cmd/drive_download.go b/internal/cmd/drive_download.go new file mode 100644 index 0000000..c596ce7 --- /dev/null +++ b/internal/cmd/drive_download.go @@ -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 + } +} diff --git a/internal/cmd/drive_raw.go b/internal/cmd/drive_raw.go new file mode 100644 index 0000000..1e5d74e --- /dev/null +++ b/internal/cmd/drive_raw.go @@ -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) +} diff --git a/internal/cmd/drive_sharing.go b/internal/cmd/drive_sharing.go new file mode 100644 index 0000000..73a924b --- /dev/null +++ b/internal/cmd/drive_sharing.go @@ -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 +} diff --git a/internal/cmd/drive_upload.go b/internal/cmd/drive_upload.go index 1a39c27..929699b 100644 --- a/internal/cmd/drive_upload.go +++ b/internal/cmd/drive_upload.go @@ -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 {