From 3cd1925a1633105a4764c11c8beb549a38f54c88 Mon Sep 17 00:00:00 2001 From: Omri Ariav Date: Wed, 18 Feb 2026 22:54:36 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(drive):=20full=20Drive=20API=20parity?= =?UTF-8?q?=20=E2=80=94=2025=20new=20commands=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 25 new commands to gws drive for complete Google Drive API coverage: Permissions (5): permissions, share, unshare, permission, update-permission Revisions (3): revisions, revision, delete-revision Replies (4): replies, reply, get-reply, delete-reply Comments (3): comment, add-comment, delete-comment Files (3): export, empty-trash, update Shared Drives (5): shared-drives, shared-drive, create-drive, delete-drive, update-drive Other (2): about, changes All commands follow existing patterns with SupportsAllDrives where applicable. Includes comprehensive tests and updated skill documentation (v2.0.0). Co-Authored-By: Claude Opus 4.6 --- cmd/commands_test.go | 25 + cmd/drive.go | 1488 +++++++++++++++++++++++++++ cmd/drive_test.go | 906 ++++++++++++++++ skills/drive/SKILL.md | 267 ++++- skills/drive/references/commands.md | 416 +++++++- 5 files changed, 3031 insertions(+), 71 deletions(-) diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 2385daa..69f7c6c 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -277,6 +277,31 @@ func TestDriveCommands(t *testing.T) { {"move"}, {"delete"}, {"copy"}, + {"permissions"}, + {"share"}, + {"unshare"}, + {"permission"}, + {"update-permission"}, + {"revisions"}, + {"revision"}, + {"delete-revision"}, + {"replies"}, + {"reply"}, + {"get-reply"}, + {"delete-reply"}, + {"comment"}, + {"add-comment"}, + {"delete-comment"}, + {"export"}, + {"empty-trash"}, + {"update"}, + {"shared-drives"}, + {"shared-drive"}, + {"create-drive"}, + {"delete-drive"}, + {"update-drive"}, + {"about"}, + {"changes"}, } for _, tt := range tests { diff --git a/cmd/drive.go b/cmd/drive.go index 2bbff47..640e418 100644 --- a/cmd/drive.go +++ b/cmd/drive.go @@ -7,6 +7,7 @@ import ( "mime" "os" "path/filepath" + "time" "github.com/omriariav/workspace-cli/internal/client" "github.com/omriariav/workspace-cli/internal/printer" @@ -131,6 +132,211 @@ Examples: RunE: runDriveCopy, } +// --- Permissions --- + +var drivePermissionsCmd = &cobra.Command{ + Use: "permissions", + Short: "List permissions on a file", + Long: "Lists all permissions on a Google Drive file.", + RunE: runDrivePermissions, +} + +var driveShareCmd = &cobra.Command{ + Use: "share", + Short: "Share a file", + Long: `Shares a file with a user, group, domain, or anyone. + +Examples: + gws drive share --file-id --type user --role writer --email user@example.com + gws drive share --file-id --type domain --role reader --domain example.com + gws drive share --file-id --type anyone --role reader`, + RunE: runDriveShare, +} + +var driveUnshareCmd = &cobra.Command{ + Use: "unshare", + Short: "Remove a permission", + Long: "Removes a permission from a Google Drive file.", + RunE: runDriveUnshare, +} + +var drivePermissionCmd = &cobra.Command{ + Use: "permission", + Short: "Get permission details", + Long: "Gets details of a specific permission on a file.", + RunE: runDrivePermission, +} + +var driveUpdatePermissionCmd = &cobra.Command{ + Use: "update-permission", + Short: "Update a permission", + Long: "Updates the role of an existing permission on a file.", + RunE: runDriveUpdatePermission, +} + +// --- Revisions --- + +var driveRevisionsCmd = &cobra.Command{ + Use: "revisions", + Short: "List file revisions", + Long: "Lists all revisions of a Google Drive file.", + RunE: runDriveRevisions, +} + +var driveRevisionCmd = &cobra.Command{ + Use: "revision", + Short: "Get revision details", + Long: "Gets details of a specific file revision.", + RunE: runDriveRevision, +} + +var driveDeleteRevisionCmd = &cobra.Command{ + Use: "delete-revision", + Short: "Delete a revision", + Long: "Deletes a specific revision of a file.", + RunE: runDriveDeleteRevision, +} + +// --- Replies --- + +var driveRepliesCmd = &cobra.Command{ + Use: "replies", + Short: "List replies to a comment", + Long: "Lists all replies to a comment on a Google Drive file.", + RunE: runDriveReplies, +} + +var driveReplyCmd = &cobra.Command{ + Use: "reply", + Short: "Reply to a comment", + Long: "Creates a reply to a comment on a Google Drive file.", + RunE: runDriveReply, +} + +var driveGetReplyCmd = &cobra.Command{ + Use: "get-reply", + Short: "Get a reply", + Long: "Gets a specific reply to a comment.", + RunE: runDriveGetReply, +} + +var driveDeleteReplyCmd = &cobra.Command{ + Use: "delete-reply", + Short: "Delete a reply", + Long: "Deletes a reply to a comment.", + RunE: runDriveDeleteReply, +} + +// --- Comments (single) --- + +var driveCommentCmd = &cobra.Command{ + Use: "comment", + Short: "Get a comment", + Long: "Gets a specific comment on a Google Drive file.", + RunE: runDriveComment, +} + +var driveAddCommentCmd = &cobra.Command{ + Use: "add-comment", + Short: "Add a comment", + Long: "Adds a comment to a Google Drive file.", + RunE: runDriveAddComment, +} + +var driveDeleteCommentCmd = &cobra.Command{ + Use: "delete-comment", + Short: "Delete a comment", + Long: "Deletes a comment from a Google Drive file.", + RunE: runDriveDeleteComment, +} + +// --- Files --- + +var driveExportCmd = &cobra.Command{ + Use: "export", + Short: "Export a Google Workspace file", + Long: `Exports a Google Workspace file (Docs, Sheets, Slides) to a specified format. + +Examples: + gws drive export --file-id --mime-type application/pdf --output report.pdf + gws drive export --file-id --mime-type text/csv --output data.csv`, + RunE: runDriveExport, +} + +var driveEmptyTrashCmd = &cobra.Command{ + Use: "empty-trash", + Short: "Empty trash", + Long: "Permanently deletes all files in the trash.", + RunE: runDriveEmptyTrash, +} + +var driveUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update file metadata", + Long: `Updates metadata of a file in Google Drive. + +Examples: + gws drive update --file-id --name "New Name" + gws drive update --file-id --starred + gws drive update --file-id --description "Updated description"`, + RunE: runDriveUpdate, +} + +// --- Shared Drives --- + +var driveSharedDrivesCmd = &cobra.Command{ + Use: "shared-drives", + Short: "List shared drives", + Long: "Lists all shared drives the user has access to.", + RunE: runDriveSharedDrives, +} + +var driveSharedDriveCmd = &cobra.Command{ + Use: "shared-drive", + Short: "Get shared drive info", + Long: "Gets information about a shared drive.", + RunE: runDriveSharedDrive, +} + +var driveCreateDriveCmd = &cobra.Command{ + Use: "create-drive", + Short: "Create a shared drive", + Long: "Creates a new shared drive.", + RunE: runDriveCreateDrive, +} + +var driveDeleteDriveCmd = &cobra.Command{ + Use: "delete-drive", + Short: "Delete a shared drive", + Long: "Deletes a shared drive.", + RunE: runDriveDeleteDrive, +} + +var driveUpdateDriveCmd = &cobra.Command{ + Use: "update-drive", + Short: "Update a shared drive", + Long: "Updates the name of a shared drive.", + RunE: runDriveUpdateDrive, +} + +// --- Other --- + +var driveAboutCmd = &cobra.Command{ + Use: "about", + Short: "Get drive storage and user info", + Long: "Gets information about the user's Drive storage quota and account.", + RunE: runDriveAbout, +} + +var driveChangesCmd = &cobra.Command{ + Use: "changes", + Short: "List recent file changes", + Long: `Lists recent changes to files in Google Drive. + +If no page token is provided, fetches the start page token automatically.`, + RunE: runDriveChanges, +} + func init() { rootCmd.AddCommand(driveCmd) driveCmd.AddCommand(driveListCmd) @@ -180,6 +386,158 @@ func init() { // Copy flags driveCopyCmd.Flags().String("name", "", "Name for the copy (default: 'Copy of ')") driveCopyCmd.Flags().String("folder", "", "Destination folder ID") + + // --- New commands --- + driveCmd.AddCommand(drivePermissionsCmd) + driveCmd.AddCommand(driveShareCmd) + driveCmd.AddCommand(driveUnshareCmd) + driveCmd.AddCommand(drivePermissionCmd) + driveCmd.AddCommand(driveUpdatePermissionCmd) + driveCmd.AddCommand(driveRevisionsCmd) + driveCmd.AddCommand(driveRevisionCmd) + driveCmd.AddCommand(driveDeleteRevisionCmd) + driveCmd.AddCommand(driveRepliesCmd) + driveCmd.AddCommand(driveReplyCmd) + driveCmd.AddCommand(driveGetReplyCmd) + driveCmd.AddCommand(driveDeleteReplyCmd) + driveCmd.AddCommand(driveCommentCmd) + driveCmd.AddCommand(driveAddCommentCmd) + driveCmd.AddCommand(driveDeleteCommentCmd) + driveCmd.AddCommand(driveExportCmd) + driveCmd.AddCommand(driveEmptyTrashCmd) + driveCmd.AddCommand(driveUpdateCmd) + driveCmd.AddCommand(driveSharedDrivesCmd) + driveCmd.AddCommand(driveSharedDriveCmd) + driveCmd.AddCommand(driveCreateDriveCmd) + driveCmd.AddCommand(driveDeleteDriveCmd) + driveCmd.AddCommand(driveUpdateDriveCmd) + driveCmd.AddCommand(driveAboutCmd) + driveCmd.AddCommand(driveChangesCmd) + + // Permissions flags + drivePermissionsCmd.Flags().String("file-id", "", "File ID (required)") + drivePermissionsCmd.MarkFlagRequired("file-id") + + driveShareCmd.Flags().String("file-id", "", "File ID (required)") + driveShareCmd.Flags().String("type", "", "Permission type: user, group, domain, anyone (required)") + driveShareCmd.Flags().String("role", "", "Role: reader, commenter, writer, organizer, owner (required)") + driveShareCmd.Flags().String("email", "", "Email address (for user/group type)") + driveShareCmd.Flags().String("domain", "", "Domain (for domain type)") + driveShareCmd.Flags().Bool("send-notification", true, "Send notification email") + driveShareCmd.MarkFlagRequired("file-id") + driveShareCmd.MarkFlagRequired("type") + driveShareCmd.MarkFlagRequired("role") + + driveUnshareCmd.Flags().String("file-id", "", "File ID (required)") + driveUnshareCmd.Flags().String("permission-id", "", "Permission ID (required)") + driveUnshareCmd.MarkFlagRequired("file-id") + driveUnshareCmd.MarkFlagRequired("permission-id") + + drivePermissionCmd.Flags().String("file-id", "", "File ID (required)") + drivePermissionCmd.Flags().String("permission-id", "", "Permission ID (required)") + drivePermissionCmd.MarkFlagRequired("file-id") + drivePermissionCmd.MarkFlagRequired("permission-id") + + driveUpdatePermissionCmd.Flags().String("file-id", "", "File ID (required)") + driveUpdatePermissionCmd.Flags().String("permission-id", "", "Permission ID (required)") + driveUpdatePermissionCmd.Flags().String("role", "", "New role (required)") + driveUpdatePermissionCmd.MarkFlagRequired("file-id") + driveUpdatePermissionCmd.MarkFlagRequired("permission-id") + driveUpdatePermissionCmd.MarkFlagRequired("role") + + // Revisions flags + driveRevisionsCmd.Flags().String("file-id", "", "File ID (required)") + driveRevisionsCmd.MarkFlagRequired("file-id") + + driveRevisionCmd.Flags().String("file-id", "", "File ID (required)") + driveRevisionCmd.Flags().String("revision-id", "", "Revision ID (required)") + driveRevisionCmd.MarkFlagRequired("file-id") + driveRevisionCmd.MarkFlagRequired("revision-id") + + driveDeleteRevisionCmd.Flags().String("file-id", "", "File ID (required)") + driveDeleteRevisionCmd.Flags().String("revision-id", "", "Revision ID (required)") + driveDeleteRevisionCmd.MarkFlagRequired("file-id") + driveDeleteRevisionCmd.MarkFlagRequired("revision-id") + + // Replies flags + driveRepliesCmd.Flags().String("file-id", "", "File ID (required)") + driveRepliesCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveRepliesCmd.MarkFlagRequired("file-id") + driveRepliesCmd.MarkFlagRequired("comment-id") + + driveReplyCmd.Flags().String("file-id", "", "File ID (required)") + driveReplyCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveReplyCmd.Flags().String("content", "", "Reply content (required)") + driveReplyCmd.MarkFlagRequired("file-id") + driveReplyCmd.MarkFlagRequired("comment-id") + driveReplyCmd.MarkFlagRequired("content") + + driveGetReplyCmd.Flags().String("file-id", "", "File ID (required)") + driveGetReplyCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveGetReplyCmd.Flags().String("reply-id", "", "Reply ID (required)") + driveGetReplyCmd.MarkFlagRequired("file-id") + driveGetReplyCmd.MarkFlagRequired("comment-id") + driveGetReplyCmd.MarkFlagRequired("reply-id") + + driveDeleteReplyCmd.Flags().String("file-id", "", "File ID (required)") + driveDeleteReplyCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveDeleteReplyCmd.Flags().String("reply-id", "", "Reply ID (required)") + driveDeleteReplyCmd.MarkFlagRequired("file-id") + driveDeleteReplyCmd.MarkFlagRequired("comment-id") + driveDeleteReplyCmd.MarkFlagRequired("reply-id") + + // Comment flags + driveCommentCmd.Flags().String("file-id", "", "File ID (required)") + driveCommentCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveCommentCmd.MarkFlagRequired("file-id") + driveCommentCmd.MarkFlagRequired("comment-id") + + driveAddCommentCmd.Flags().String("file-id", "", "File ID (required)") + driveAddCommentCmd.Flags().String("content", "", "Comment content (required)") + driveAddCommentCmd.MarkFlagRequired("file-id") + driveAddCommentCmd.MarkFlagRequired("content") + + driveDeleteCommentCmd.Flags().String("file-id", "", "File ID (required)") + driveDeleteCommentCmd.Flags().String("comment-id", "", "Comment ID (required)") + driveDeleteCommentCmd.MarkFlagRequired("file-id") + driveDeleteCommentCmd.MarkFlagRequired("comment-id") + + // Export flags + driveExportCmd.Flags().String("file-id", "", "File ID (required)") + driveExportCmd.Flags().String("mime-type", "", "Export MIME type (required, e.g. application/pdf)") + driveExportCmd.Flags().String("output", "", "Output file path (required)") + driveExportCmd.MarkFlagRequired("file-id") + driveExportCmd.MarkFlagRequired("mime-type") + driveExportCmd.MarkFlagRequired("output") + + // Update flags + driveUpdateCmd.Flags().String("file-id", "", "File ID (required)") + driveUpdateCmd.Flags().String("name", "", "New file name") + driveUpdateCmd.Flags().String("description", "", "New description") + driveUpdateCmd.Flags().Bool("starred", false, "Star or unstar the file") + driveUpdateCmd.Flags().Bool("trashed", false, "Trash or untrash the file") + driveUpdateCmd.MarkFlagRequired("file-id") + + // Shared Drives flags + driveSharedDrivesCmd.Flags().Int64("max", 100, "Maximum number of shared drives") + driveSharedDrivesCmd.Flags().String("query", "", "Search query") + + driveSharedDriveCmd.Flags().String("id", "", "Shared drive ID (required)") + driveSharedDriveCmd.MarkFlagRequired("id") + + driveCreateDriveCmd.Flags().String("name", "", "Shared drive name (required)") + driveCreateDriveCmd.MarkFlagRequired("name") + + driveDeleteDriveCmd.Flags().String("id", "", "Shared drive ID (required)") + driveDeleteDriveCmd.MarkFlagRequired("id") + + driveUpdateDriveCmd.Flags().String("id", "", "Shared drive ID (required)") + driveUpdateDriveCmd.Flags().String("name", "", "New name for the shared drive") + driveUpdateDriveCmd.MarkFlagRequired("id") + + // Changes flags + driveChangesCmd.Flags().Int64("max", 100, "Maximum number of changes") + driveChangesCmd.Flags().String("page-token", "", "Page token (fetches start token if empty)") } func runDriveList(cmd *cobra.Command, args []string) error { @@ -875,3 +1233,1133 @@ func runDriveCopy(cmd *cobra.Command, args []string) error { "web_link": copied.WebViewLink, }) } + +// --- Permissions --- + +func runDrivePermissions(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + + resp, err := svc.Permissions.List(fileID). + SupportsAllDrives(true). + Fields("permissions(id,type,role,emailAddress,domain,displayName)"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list permissions: %w", err)) + } + + permissions := make([]map[string]interface{}, 0, len(resp.Permissions)) + for _, perm := range resp.Permissions { + permInfo := map[string]interface{}{ + "id": perm.Id, + "type": perm.Type, + "role": perm.Role, + } + if perm.EmailAddress != "" { + permInfo["email"] = perm.EmailAddress + } + if perm.Domain != "" { + permInfo["domain"] = perm.Domain + } + if perm.DisplayName != "" { + permInfo["display_name"] = perm.DisplayName + } + permissions = append(permissions, permInfo) + } + + return p.Print(map[string]interface{}{ + "file_id": fileID, + "permissions": permissions, + "count": len(permissions), + }) +} + +func runDriveShare(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + permType, _ := cmd.Flags().GetString("type") + role, _ := cmd.Flags().GetString("role") + email, _ := cmd.Flags().GetString("email") + domain, _ := cmd.Flags().GetString("domain") + sendNotification, _ := cmd.Flags().GetBool("send-notification") + + perm := &drive.Permission{ + Type: permType, + Role: role, + } + if email != "" { + perm.EmailAddress = email + } + if domain != "" { + perm.Domain = domain + } + + call := svc.Permissions.Create(fileID, perm). + SupportsAllDrives(true). + SendNotificationEmail(sendNotification). + Fields("id,type,role,emailAddress,domain,displayName") + + if role == "owner" { + call = call.TransferOwnership(true) + } + + created, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to share file: %w", err)) + } + + result := map[string]interface{}{ + "status": "shared", + "id": created.Id, + "type": created.Type, + "role": created.Role, + } + if created.EmailAddress != "" { + result["email"] = created.EmailAddress + } + if created.Domain != "" { + result["domain"] = created.Domain + } + + return p.Print(result) +} + +func runDriveUnshare(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + permissionID, _ := cmd.Flags().GetString("permission-id") + + err = svc.Permissions.Delete(fileID, permissionID). + SupportsAllDrives(true). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to remove permission: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "removed", + "file_id": fileID, + "permission_id": permissionID, + }) +} + +func runDrivePermission(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + permissionID, _ := cmd.Flags().GetString("permission-id") + + perm, err := svc.Permissions.Get(fileID, permissionID). + SupportsAllDrives(true). + Fields("id,type,role,emailAddress,domain,displayName"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get permission: %w", err)) + } + + result := map[string]interface{}{ + "id": perm.Id, + "type": perm.Type, + "role": perm.Role, + } + if perm.EmailAddress != "" { + result["email"] = perm.EmailAddress + } + if perm.Domain != "" { + result["domain"] = perm.Domain + } + if perm.DisplayName != "" { + result["display_name"] = perm.DisplayName + } + + return p.Print(result) +} + +func runDriveUpdatePermission(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + permissionID, _ := cmd.Flags().GetString("permission-id") + role, _ := cmd.Flags().GetString("role") + + perm := &drive.Permission{Role: role} + + call := svc.Permissions.Update(fileID, permissionID, perm). + SupportsAllDrives(true). + Fields("id,type,role,emailAddress,domain,displayName") + + if role == "owner" { + call = call.TransferOwnership(true) + } + + updated, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update permission: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "type": updated.Type, + "role": updated.Role, + } + if updated.EmailAddress != "" { + result["email"] = updated.EmailAddress + } + + return p.Print(result) +} + +// --- Revisions --- + +func runDriveRevisions(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + + resp, err := svc.Revisions.List(fileID). + Fields("revisions(id,mimeType,modifiedTime,size,lastModifyingUser(displayName,emailAddress),originalFilename,keepForever)"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list revisions: %w", err)) + } + + revisions := make([]map[string]interface{}, 0, len(resp.Revisions)) + for _, rev := range resp.Revisions { + revInfo := map[string]interface{}{ + "id": rev.Id, + } + if rev.MimeType != "" { + revInfo["mime_type"] = rev.MimeType + } + if rev.ModifiedTime != "" { + revInfo["modified"] = rev.ModifiedTime + } + if rev.Size > 0 { + revInfo["size"] = rev.Size + } + if rev.OriginalFilename != "" { + revInfo["original_filename"] = rev.OriginalFilename + } + if rev.LastModifyingUser != nil { + revInfo["last_modifying_user"] = map[string]interface{}{ + "name": rev.LastModifyingUser.DisplayName, + "email": rev.LastModifyingUser.EmailAddress, + } + } + revInfo["keep_forever"] = rev.KeepForever + revisions = append(revisions, revInfo) + } + + return p.Print(map[string]interface{}{ + "file_id": fileID, + "revisions": revisions, + "count": len(revisions), + }) +} + +func runDriveRevision(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + revisionID, _ := cmd.Flags().GetString("revision-id") + + rev, err := svc.Revisions.Get(fileID, revisionID). + Fields("id,mimeType,modifiedTime,size,lastModifyingUser(displayName,emailAddress),originalFilename,keepForever"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get revision: %w", err)) + } + + result := map[string]interface{}{ + "id": rev.Id, + "keep_forever": rev.KeepForever, + } + if rev.MimeType != "" { + result["mime_type"] = rev.MimeType + } + if rev.ModifiedTime != "" { + result["modified"] = rev.ModifiedTime + } + if rev.Size > 0 { + result["size"] = rev.Size + } + if rev.OriginalFilename != "" { + result["original_filename"] = rev.OriginalFilename + } + if rev.LastModifyingUser != nil { + result["last_modifying_user"] = map[string]interface{}{ + "name": rev.LastModifyingUser.DisplayName, + "email": rev.LastModifyingUser.EmailAddress, + } + } + + return p.Print(result) +} + +func runDriveDeleteRevision(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + revisionID, _ := cmd.Flags().GetString("revision-id") + + err = svc.Revisions.Delete(fileID, revisionID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete revision: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "file_id": fileID, + "revision_id": revisionID, + }) +} + +// --- Replies --- + +func runDriveReplies(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + + resp, err := svc.Replies.List(fileID, commentID). + Fields("replies(id,content,author(displayName,emailAddress),createdTime,modifiedTime,action)"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list replies: %w", err)) + } + + replies := make([]map[string]interface{}, 0, len(resp.Replies)) + for _, reply := range resp.Replies { + r := map[string]interface{}{ + "id": reply.Id, + "content": reply.Content, + "created": reply.CreatedTime, + } + if reply.ModifiedTime != "" { + r["modified"] = reply.ModifiedTime + } + if reply.Author != nil { + r["author"] = map[string]interface{}{ + "name": reply.Author.DisplayName, + "email": reply.Author.EmailAddress, + } + } + if reply.Action != "" { + r["action"] = reply.Action + } + replies = append(replies, r) + } + + return p.Print(map[string]interface{}{ + "file_id": fileID, + "comment_id": commentID, + "replies": replies, + "count": len(replies), + }) +} + +func runDriveReply(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + content, _ := cmd.Flags().GetString("content") + + reply := &drive.Reply{Content: content} + + created, err := svc.Replies.Create(fileID, commentID, reply). + Fields("id,content,author(displayName,emailAddress),createdTime"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to create reply: %w", err)) + } + + result := map[string]interface{}{ + "status": "created", + "id": created.Id, + "content": created.Content, + "created": created.CreatedTime, + } + if created.Author != nil { + result["author"] = map[string]interface{}{ + "name": created.Author.DisplayName, + "email": created.Author.EmailAddress, + } + } + + return p.Print(result) +} + +func runDriveGetReply(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + replyID, _ := cmd.Flags().GetString("reply-id") + + reply, err := svc.Replies.Get(fileID, commentID, replyID). + Fields("id,content,author(displayName,emailAddress),createdTime,modifiedTime,action"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get reply: %w", err)) + } + + result := map[string]interface{}{ + "id": reply.Id, + "content": reply.Content, + "created": reply.CreatedTime, + } + if reply.ModifiedTime != "" { + result["modified"] = reply.ModifiedTime + } + if reply.Author != nil { + result["author"] = map[string]interface{}{ + "name": reply.Author.DisplayName, + "email": reply.Author.EmailAddress, + } + } + if reply.Action != "" { + result["action"] = reply.Action + } + + return p.Print(result) +} + +func runDriveDeleteReply(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + replyID, _ := cmd.Flags().GetString("reply-id") + + err = svc.Replies.Delete(fileID, commentID, replyID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete reply: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "file_id": fileID, + "comment_id": commentID, + "reply_id": replyID, + }) +} + +// --- Comments (single) --- + +func runDriveComment(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + + comment, err := svc.Comments.Get(fileID, commentID). + Fields("id,content,author(displayName,emailAddress),createdTime,modifiedTime,resolved,quotedFileContent(value),replies(id,content,author(displayName,emailAddress),createdTime,action)"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get comment: %w", err)) + } + + result := map[string]interface{}{ + "id": comment.Id, + "content": comment.Content, + "created": comment.CreatedTime, + "resolved": comment.Resolved, + } + if comment.ModifiedTime != "" { + result["modified"] = comment.ModifiedTime + } + if comment.Author != nil { + result["author"] = map[string]interface{}{ + "name": comment.Author.DisplayName, + "email": comment.Author.EmailAddress, + } + } + if comment.QuotedFileContent != nil && comment.QuotedFileContent.Value != "" { + result["quoted_text"] = comment.QuotedFileContent.Value + } + if len(comment.Replies) > 0 { + replies := make([]map[string]interface{}, 0, len(comment.Replies)) + for _, reply := range comment.Replies { + r := map[string]interface{}{ + "id": reply.Id, + "content": reply.Content, + "created": reply.CreatedTime, + } + if reply.Author != nil { + r["author"] = map[string]interface{}{ + "name": reply.Author.DisplayName, + "email": reply.Author.EmailAddress, + } + } + if reply.Action != "" { + r["action"] = reply.Action + } + replies = append(replies, r) + } + result["replies"] = replies + } + + return p.Print(result) +} + +func runDriveAddComment(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + content, _ := cmd.Flags().GetString("content") + + comment := &drive.Comment{Content: content} + + created, err := svc.Comments.Create(fileID, comment). + Fields("id,content,author(displayName,emailAddress),createdTime"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to add comment: %w", err)) + } + + result := map[string]interface{}{ + "status": "created", + "id": created.Id, + "content": created.Content, + "created": created.CreatedTime, + } + if created.Author != nil { + result["author"] = map[string]interface{}{ + "name": created.Author.DisplayName, + "email": created.Author.EmailAddress, + } + } + + return p.Print(result) +} + +func runDriveDeleteComment(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + commentID, _ := cmd.Flags().GetString("comment-id") + + err = svc.Comments.Delete(fileID, commentID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete comment: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "file_id": fileID, + "comment_id": commentID, + }) +} + +// --- Files --- + +func runDriveExport(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + mimeType, _ := cmd.Flags().GetString("mime-type") + outputPath, _ := cmd.Flags().GetString("output") + + exportResp, err := svc.Files.Export(fileID, mimeType).Download() + if err != nil { + return p.PrintError(fmt.Errorf("failed to export file: %w", err)) + } + defer exportResp.Body.Close() + + outFile, err := os.Create(outputPath) + if err != nil { + return p.PrintError(fmt.Errorf("failed to create output file: %w", err)) + } + defer outFile.Close() + + written, err := io.Copy(outFile, exportResp.Body) + if err != nil { + return p.PrintError(fmt.Errorf("failed to write file: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "exported", + "file_id": fileID, + "mime_type": mimeType, + "output": outputPath, + "size": written, + }) +} + +func runDriveEmptyTrash(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + err = svc.Files.EmptyTrash().Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to empty trash: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "trash_emptied", + }) +} + +func runDriveUpdate(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + fileID, _ := cmd.Flags().GetString("file-id") + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + starred, _ := cmd.Flags().GetBool("starred") + trashed, _ := cmd.Flags().GetBool("trashed") + + file := &drive.File{} + var forceSendFields []string + + if name != "" { + file.Name = name + } + if description != "" { + file.Description = description + } + if cmd.Flags().Changed("starred") { + file.Starred = starred + forceSendFields = append(forceSendFields, "Starred") + } + if cmd.Flags().Changed("trashed") { + file.Trashed = trashed + forceSendFields = append(forceSendFields, "Trashed") + } + if len(forceSendFields) > 0 { + file.ForceSendFields = forceSendFields + } + + updated, err := svc.Files.Update(fileID, file). + SupportsAllDrives(true). + Fields("id,name,mimeType,description,starred,trashed,modifiedTime,webViewLink"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update file: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "name": updated.Name, + "starred": updated.Starred, + "trashed": updated.Trashed, + "modified": updated.ModifiedTime, + } + if updated.Description != "" { + result["description"] = updated.Description + } + if updated.WebViewLink != "" { + result["web_link"] = updated.WebViewLink + } + + return p.Print(result) +} + +// --- Shared Drives --- + +func runDriveSharedDrives(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + maxResults, _ := cmd.Flags().GetInt64("max") + query, _ := cmd.Flags().GetString("query") + + call := svc.Drives.List(). + PageSize(maxResults). + Fields("drives(id,name,createdTime,hidden)") + + if query != "" { + call = call.Q(query) + } + + resp, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list shared drives: %w", err)) + } + + drives := make([]map[string]interface{}, 0, len(resp.Drives)) + for _, d := range resp.Drives { + driveInfo := map[string]interface{}{ + "id": d.Id, + "name": d.Name, + } + if d.CreatedTime != "" { + driveInfo["created"] = d.CreatedTime + } + drives = append(drives, driveInfo) + } + + return p.Print(map[string]interface{}{ + "drives": drives, + "count": len(drives), + }) +} + +func runDriveSharedDrive(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + driveID, _ := cmd.Flags().GetString("id") + + d, err := svc.Drives.Get(driveID). + Fields("id,name,createdTime,hidden,colorRgb,restrictions"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get shared drive: %w", err)) + } + + result := map[string]interface{}{ + "id": d.Id, + "name": d.Name, + } + if d.CreatedTime != "" { + result["created"] = d.CreatedTime + } + if d.ColorRgb != "" { + result["color"] = d.ColorRgb + } + + return p.Print(result) +} + +func runDriveCreateDrive(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + name, _ := cmd.Flags().GetString("name") + requestID := fmt.Sprintf("%d", time.Now().UnixNano()) + + d := &drive.Drive{Name: name} + + created, err := svc.Drives.Create(requestID, d). + Fields("id,name,createdTime"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to create shared drive: %w", err)) + } + + result := map[string]interface{}{ + "status": "created", + "id": created.Id, + "name": created.Name, + } + if created.CreatedTime != "" { + result["created"] = created.CreatedTime + } + + return p.Print(result) +} + +func runDriveDeleteDrive(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + driveID, _ := cmd.Flags().GetString("id") + + err = svc.Drives.Delete(driveID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete shared drive: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "id": driveID, + }) +} + +func runDriveUpdateDrive(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + driveID, _ := cmd.Flags().GetString("id") + name, _ := cmd.Flags().GetString("name") + + d := &drive.Drive{} + if name != "" { + d.Name = name + } + + updated, err := svc.Drives.Update(driveID, d). + Fields("id,name"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update shared drive: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "name": updated.Name, + }) +} + +// --- Other --- + +func runDriveAbout(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + about, err := svc.About.Get(). + Fields("user(displayName,emailAddress),storageQuota(limit,usage,usageInDrive,usageInDriveTrash)"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get drive info: %w", err)) + } + + result := map[string]interface{}{} + if about.User != nil { + result["user"] = map[string]interface{}{ + "name": about.User.DisplayName, + "email": about.User.EmailAddress, + } + } + if about.StorageQuota != nil { + result["storage_quota"] = map[string]interface{}{ + "limit": about.StorageQuota.Limit, + "usage": about.StorageQuota.Usage, + "usage_in_drive": about.StorageQuota.UsageInDrive, + "usage_in_trash": about.StorageQuota.UsageInDriveTrash, + } + } + + return p.Print(result) +} + +func runDriveChanges(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Drive() + if err != nil { + return p.PrintError(err) + } + + maxResults, _ := cmd.Flags().GetInt64("max") + pageToken, _ := cmd.Flags().GetString("page-token") + + // If no page token provided, get the start page token + if pageToken == "" { + startToken, err := svc.Changes.GetStartPageToken(). + SupportsAllDrives(true). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get start page token: %w", err)) + } + pageToken = startToken.StartPageToken + } + + resp, err := svc.Changes.List(pageToken). + PageSize(maxResults). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true). + Fields("changes(fileId,file(id,name,mimeType,modifiedTime),removed,time,changeType),newStartPageToken,nextPageToken"). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list changes: %w", err)) + } + + changes := make([]map[string]interface{}, 0, len(resp.Changes)) + for _, change := range resp.Changes { + c := map[string]interface{}{ + "file_id": change.FileId, + "removed": change.Removed, + } + if change.Time != "" { + c["time"] = change.Time + } + if change.ChangeType != "" { + c["change_type"] = change.ChangeType + } + if change.File != nil { + c["file"] = map[string]interface{}{ + "id": change.File.Id, + "name": change.File.Name, + "mime_type": change.File.MimeType, + "modified": change.File.ModifiedTime, + } + } + changes = append(changes, c) + } + + result := map[string]interface{}{ + "changes": changes, + "count": len(changes), + } + if resp.NewStartPageToken != "" { + result["new_start_page_token"] = resp.NewStartPageToken + } + if resp.NextPageToken != "" { + result["next_page_token"] = resp.NextPageToken + } + + return p.Print(result) +} diff --git a/cmd/drive_test.go b/cmd/drive_test.go index 59398e1..02c87bd 100644 --- a/cmd/drive_test.go +++ b/cmd/drive_test.go @@ -913,3 +913,909 @@ func TestDriveDelete_OutputFormat(t *testing.T) { }) } } + +// --- Permissions Tests --- + +func TestDrivePermissionsCommand_Flags(t *testing.T) { + cmd := drivePermissionsCmd + if cmd.Flags().Lookup("file-id") == nil { + t.Error("expected --file-id flag") + } +} + +func TestDriveShareCommand_Flags(t *testing.T) { + cmd := driveShareCmd + flags := []string{"file-id", "type", "role", "email", "domain", "send-notification"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveUnshareCommand_Flags(t *testing.T) { + cmd := driveUnshareCmd + flags := []string{"file-id", "permission-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDrivePermissionCommand_Flags(t *testing.T) { + cmd := drivePermissionCmd + flags := []string{"file-id", "permission-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveUpdatePermissionCommand_Flags(t *testing.T) { + cmd := driveUpdatePermissionCmd + flags := []string{"file-id", "permission-id", "role"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDrivePermissions_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id/permissions" && r.Method == "GET" { + resp := &drive.PermissionList{ + Permissions: []*drive.Permission{ + { + Id: "perm-1", + Type: "user", + Role: "writer", + EmailAddress: "user@example.com", + DisplayName: "Test User", + }, + { + Id: "perm-2", + Type: "anyone", + Role: "reader", + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + resp, err := svc.Permissions.List("test-file-id"). + Fields("permissions(id,type,role,emailAddress,displayName)"). + Do() + if err != nil { + t.Fatalf("failed to list permissions: %v", err) + } + + if len(resp.Permissions) != 2 { + t.Fatalf("expected 2 permissions, got %d", len(resp.Permissions)) + } + + if resp.Permissions[0].Role != "writer" { + t.Errorf("expected role 'writer', got '%s'", resp.Permissions[0].Role) + } +} + +func TestDriveShare_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id/permissions" && r.Method == "POST" { + var perm drive.Permission + json.NewDecoder(r.Body).Decode(&perm) + + resp := &drive.Permission{ + Id: "new-perm-id", + Type: perm.Type, + Role: perm.Role, + EmailAddress: perm.EmailAddress, + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + perm := &drive.Permission{ + Type: "user", + Role: "writer", + EmailAddress: "user@example.com", + } + + created, err := svc.Permissions.Create("test-file-id", perm). + Fields("id,type,role,emailAddress"). + Do() + if err != nil { + t.Fatalf("failed to create permission: %v", err) + } + + if created.Id != "new-perm-id" { + t.Errorf("expected id 'new-perm-id', got '%s'", created.Id) + } + if created.Role != "writer" { + t.Errorf("expected role 'writer', got '%s'", created.Role) + } +} + +func TestDriveUnshare_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/files/test-file-id/permissions/perm-1" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + err = svc.Permissions.Delete("test-file-id", "perm-1").Do() + if err != nil { + t.Fatalf("failed to delete permission: %v", err) + } +} + +// --- Revisions Tests --- + +func TestDriveRevisionsCommand_Flags(t *testing.T) { + cmd := driveRevisionsCmd + if cmd.Flags().Lookup("file-id") == nil { + t.Error("expected --file-id flag") + } +} + +func TestDriveRevisionCommand_Flags(t *testing.T) { + cmd := driveRevisionCmd + flags := []string{"file-id", "revision-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveDeleteRevisionCommand_Flags(t *testing.T) { + cmd := driveDeleteRevisionCmd + flags := []string{"file-id", "revision-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveRevisions_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id/revisions" && r.Method == "GET" { + resp := &drive.RevisionList{ + Revisions: []*drive.Revision{ + { + Id: "rev-1", + MimeType: "application/vnd.google-apps.document", + ModifiedTime: "2024-01-15T10:00:00Z", + Size: 1024, + LastModifyingUser: &drive.User{ + DisplayName: "Test User", + EmailAddress: "test@example.com", + }, + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/revisions/rev-1" && r.Method == "GET" { + resp := &drive.Revision{ + Id: "rev-1", + MimeType: "application/vnd.google-apps.document", + ModifiedTime: "2024-01-15T10:00:00Z", + Size: 1024, + KeepForever: true, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/revisions/rev-1" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + // Test list revisions + revList, err := svc.Revisions.List("test-file-id").Do() + if err != nil { + t.Fatalf("failed to list revisions: %v", err) + } + if len(revList.Revisions) != 1 { + t.Fatalf("expected 1 revision, got %d", len(revList.Revisions)) + } + + // Test get revision + rev, err := svc.Revisions.Get("test-file-id", "rev-1").Do() + if err != nil { + t.Fatalf("failed to get revision: %v", err) + } + if rev.Id != "rev-1" { + t.Errorf("expected id 'rev-1', got '%s'", rev.Id) + } + + // Test delete revision + err = svc.Revisions.Delete("test-file-id", "rev-1").Do() + if err != nil { + t.Fatalf("failed to delete revision: %v", err) + } +} + +// --- Replies Tests --- + +func TestDriveRepliesCommand_Flags(t *testing.T) { + cmd := driveRepliesCmd + flags := []string{"file-id", "comment-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveReplyCommand_Flags(t *testing.T) { + cmd := driveReplyCmd + flags := []string{"file-id", "comment-id", "content"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveGetReplyCommand_Flags(t *testing.T) { + cmd := driveGetReplyCmd + flags := []string{"file-id", "comment-id", "reply-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveDeleteReplyCommand_Flags(t *testing.T) { + cmd := driveDeleteReplyCmd + flags := []string{"file-id", "comment-id", "reply-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveReplies_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id/comments/comment-1/replies" && r.Method == "GET" { + resp := &drive.ReplyList{ + Replies: []*drive.Reply{ + { + Id: "reply-1", + Content: "Test reply", + CreatedTime: "2024-01-15T10:00:00Z", + Author: &drive.User{ + DisplayName: "Test User", + EmailAddress: "test@example.com", + }, + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/comments/comment-1/replies" && r.Method == "POST" { + var reply drive.Reply + json.NewDecoder(r.Body).Decode(&reply) + resp := &drive.Reply{ + Id: "new-reply-id", + Content: reply.Content, + CreatedTime: "2024-01-15T11:00:00Z", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/comments/comment-1/replies/reply-1" && r.Method == "GET" { + resp := &drive.Reply{ + Id: "reply-1", + Content: "Test reply", + CreatedTime: "2024-01-15T10:00:00Z", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/comments/comment-1/replies/reply-1" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + // Test list replies + replyList, err := svc.Replies.List("test-file-id", "comment-1").Do() + if err != nil { + t.Fatalf("failed to list replies: %v", err) + } + if len(replyList.Replies) != 1 { + t.Fatalf("expected 1 reply, got %d", len(replyList.Replies)) + } + + // Test create reply + newReply, err := svc.Replies.Create("test-file-id", "comment-1", &drive.Reply{Content: "New reply"}).Do() + if err != nil { + t.Fatalf("failed to create reply: %v", err) + } + if newReply.Id != "new-reply-id" { + t.Errorf("expected id 'new-reply-id', got '%s'", newReply.Id) + } + + // Test get reply + reply, err := svc.Replies.Get("test-file-id", "comment-1", "reply-1").Do() + if err != nil { + t.Fatalf("failed to get reply: %v", err) + } + if reply.Content != "Test reply" { + t.Errorf("unexpected content: %s", reply.Content) + } + + // Test delete reply + err = svc.Replies.Delete("test-file-id", "comment-1", "reply-1").Do() + if err != nil { + t.Fatalf("failed to delete reply: %v", err) + } +} + +// --- Comment (single) Tests --- + +func TestDriveCommentCommand_Flags(t *testing.T) { + cmd := driveCommentCmd + flags := []string{"file-id", "comment-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveAddCommentCommand_Flags(t *testing.T) { + cmd := driveAddCommentCmd + flags := []string{"file-id", "content"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveDeleteCommentCommand_Flags(t *testing.T) { + cmd := driveDeleteCommentCmd + flags := []string{"file-id", "comment-id"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveComment_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id/comments/comment-1" && r.Method == "GET" { + resp := &drive.Comment{ + Id: "comment-1", + Content: "Test comment", + CreatedTime: "2024-01-15T10:00:00Z", + Resolved: false, + Author: &drive.User{ + DisplayName: "Test User", + EmailAddress: "test@example.com", + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/comments" && r.Method == "POST" { + var comment drive.Comment + json.NewDecoder(r.Body).Decode(&comment) + resp := &drive.Comment{ + Id: "new-comment-id", + Content: comment.Content, + CreatedTime: "2024-01-15T11:00:00Z", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/files/test-file-id/comments/comment-1" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + // Test get comment + comment, err := svc.Comments.Get("test-file-id", "comment-1").Do() + if err != nil { + t.Fatalf("failed to get comment: %v", err) + } + if comment.Content != "Test comment" { + t.Errorf("unexpected content: %s", comment.Content) + } + + // Test create comment + newComment, err := svc.Comments.Create("test-file-id", &drive.Comment{Content: "New comment"}).Do() + if err != nil { + t.Fatalf("failed to create comment: %v", err) + } + if newComment.Id != "new-comment-id" { + t.Errorf("expected id 'new-comment-id', got '%s'", newComment.Id) + } + + // Test delete comment + err = svc.Comments.Delete("test-file-id", "comment-1").Do() + if err != nil { + t.Fatalf("failed to delete comment: %v", err) + } +} + +// --- Export Tests --- + +func TestDriveExportCommand_Flags(t *testing.T) { + cmd := driveExportCmd + flags := []string{"file-id", "mime-type", "output"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +// --- Empty Trash Tests --- + +func TestDriveEmptyTrashCommand_Help(t *testing.T) { + cmd := driveEmptyTrashCmd + if cmd.Use != "empty-trash" { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("expected Short description to be set") + } +} + +func TestDriveEmptyTrash_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/files/trash" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + err = svc.Files.EmptyTrash().Do() + if err != nil { + t.Fatalf("failed to empty trash: %v", err) + } +} + +// --- Update Tests --- + +func TestDriveUpdateCommand_Flags(t *testing.T) { + cmd := driveUpdateCmd + flags := []string{"file-id", "name", "description", "starred", "trashed"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveUpdate_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/files/test-file-id" && r.Method == "PATCH" { + resp := &drive.File{ + Id: "test-file-id", + Name: "Updated Name", + Starred: true, + ModifiedTime: "2024-01-15T12:00:00Z", + WebViewLink: "https://drive.google.com/file/d/test-file-id/view", + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + updated, err := svc.Files.Update("test-file-id", &drive.File{ + Name: "Updated Name", + Starred: true, + }).Fields("id,name,starred,modifiedTime,webViewLink").Do() + if err != nil { + t.Fatalf("failed to update file: %v", err) + } + + if updated.Name != "Updated Name" { + t.Errorf("unexpected name: %s", updated.Name) + } + if !updated.Starred { + t.Error("expected starred to be true") + } +} + +// --- Shared Drives Tests --- + +func TestDriveSharedDrivesCommand_Flags(t *testing.T) { + cmd := driveSharedDrivesCmd + flags := []string{"max", "query"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveSharedDriveCommand_Flags(t *testing.T) { + cmd := driveSharedDriveCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag") + } +} + +func TestDriveCreateDriveCommand_Flags(t *testing.T) { + cmd := driveCreateDriveCmd + if cmd.Flags().Lookup("name") == nil { + t.Error("expected --name flag") + } +} + +func TestDriveDeleteDriveCommand_Flags(t *testing.T) { + cmd := driveDeleteDriveCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag") + } +} + +func TestDriveUpdateDriveCommand_Flags(t *testing.T) { + cmd := driveUpdateDriveCmd + flags := []string{"id", "name"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveSharedDrives_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/drives" && r.Method == "GET" { + resp := &drive.DriveList{ + Drives: []*drive.Drive{ + { + Id: "drive-1", + Name: "Engineering", + CreatedTime: "2024-01-01T00:00:00Z", + }, + { + Id: "drive-2", + Name: "Marketing", + CreatedTime: "2024-02-01T00:00:00Z", + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/drives/drive-1" && r.Method == "GET" { + resp := &drive.Drive{ + Id: "drive-1", + Name: "Engineering", + CreatedTime: "2024-01-01T00:00:00Z", + ColorRgb: "#4285f4", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/drives" && r.Method == "POST" { + resp := &drive.Drive{ + Id: "new-drive-id", + Name: "New Drive", + CreatedTime: "2024-03-01T00:00:00Z", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/drives/drive-1" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.URL.Path == "/drives/drive-1" && r.Method == "PATCH" { + resp := &drive.Drive{ + Id: "drive-1", + Name: "Updated Engineering", + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + // Test list shared drives + driveList, err := svc.Drives.List().Do() + if err != nil { + t.Fatalf("failed to list drives: %v", err) + } + if len(driveList.Drives) != 2 { + t.Fatalf("expected 2 drives, got %d", len(driveList.Drives)) + } + + // Test get shared drive + d, err := svc.Drives.Get("drive-1").Do() + if err != nil { + t.Fatalf("failed to get drive: %v", err) + } + if d.Name != "Engineering" { + t.Errorf("unexpected name: %s", d.Name) + } + + // Test create shared drive + created, err := svc.Drives.Create("req-123", &drive.Drive{Name: "New Drive"}).Do() + if err != nil { + t.Fatalf("failed to create drive: %v", err) + } + if created.Id != "new-drive-id" { + t.Errorf("unexpected id: %s", created.Id) + } + + // Test delete shared drive + err = svc.Drives.Delete("drive-1").Do() + if err != nil { + t.Fatalf("failed to delete drive: %v", err) + } + + // Test update shared drive + updated, err := svc.Drives.Update("drive-1", &drive.Drive{Name: "Updated Engineering"}).Do() + if err != nil { + t.Fatalf("failed to update drive: %v", err) + } + if updated.Name != "Updated Engineering" { + t.Errorf("unexpected name: %s", updated.Name) + } +} + +// --- About Tests --- + +func TestDriveAboutCommand_Help(t *testing.T) { + cmd := driveAboutCmd + if cmd.Use != "about" { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("expected Short description to be set") + } +} + +func TestDriveAbout_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/about" && r.Method == "GET" { + resp := &drive.About{ + User: &drive.User{ + DisplayName: "Test User", + EmailAddress: "test@example.com", + }, + StorageQuota: &drive.AboutStorageQuota{ + Limit: 16106127360, + Usage: 5368709120, + UsageInDrive: 4294967296, + UsageInDriveTrash: 1073741824, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + about, err := svc.About.Get(). + Fields("user(displayName,emailAddress),storageQuota(limit,usage,usageInDrive,usageInDriveTrash)"). + Do() + if err != nil { + t.Fatalf("failed to get about: %v", err) + } + + if about.User.EmailAddress != "test@example.com" { + t.Errorf("unexpected email: %s", about.User.EmailAddress) + } + if about.StorageQuota.Limit != 16106127360 { + t.Errorf("unexpected storage limit: %d", about.StorageQuota.Limit) + } +} + +// --- Changes Tests --- + +func TestDriveChangesCommand_Flags(t *testing.T) { + cmd := driveChangesCmd + flags := []string{"max", "page-token"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +func TestDriveChanges_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/changes/startPageToken" && r.Method == "GET" { + resp := &drive.StartPageToken{ + StartPageToken: "start-token-123", + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/changes" && r.Method == "GET" { + resp := &drive.ChangeList{ + Changes: []*drive.Change{ + { + FileId: "file-1", + ChangeType: "file", + Time: "2024-01-15T10:00:00Z", + Removed: false, + File: &drive.File{ + Id: "file-1", + Name: "Modified File.docx", + MimeType: "application/vnd.google-apps.document", + ModifiedTime: "2024-01-15T10:00:00Z", + }, + }, + }, + NewStartPageToken: "new-token-456", + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create drive service: %v", err) + } + + // Test get start page token + startToken, err := svc.Changes.GetStartPageToken().Do() + if err != nil { + t.Fatalf("failed to get start token: %v", err) + } + if startToken.StartPageToken != "start-token-123" { + t.Errorf("unexpected start token: %s", startToken.StartPageToken) + } + + // Test list changes + changes, err := svc.Changes.List("start-token-123").Do() + if err != nil { + t.Fatalf("failed to list changes: %v", err) + } + if len(changes.Changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes.Changes)) + } + if changes.Changes[0].FileId != "file-1" { + t.Errorf("unexpected file id: %s", changes.Changes[0].FileId) + } + if changes.NewStartPageToken != "new-token-456" { + t.Errorf("unexpected new start token: %s", changes.NewStartPageToken) + } +} diff --git a/skills/drive/SKILL.md b/skills/drive/SKILL.md index efa2a6b..335fa67 100644 --- a/skills/drive/SKILL.md +++ b/skills/drive/SKILL.md @@ -1,7 +1,7 @@ --- name: gws-drive -version: 1.1.0 -description: "Google Drive CLI operations via gws. Use when users need to list, search, upload, download, or manage files and folders in Google Drive. Triggers: drive, files, upload, download, folders, google drive, file management." +version: 2.0.0 +description: "Google Drive CLI operations via gws. Use when users need to list, search, upload, download, manage files/folders, permissions, revisions, comments, shared drives, and more. Triggers: drive, files, upload, download, folders, google drive, file management, permissions, share, shared drives." metadata: short-description: Google Drive CLI operations compatibility: claude-code, codex-cli @@ -30,6 +30,7 @@ For initial setup, see the `gws-auth` skill. ## Quick Command Reference +### Files & Folders | Task | Command | |------|---------| | List files | `gws drive list` | @@ -38,13 +39,53 @@ For initial setup, see the `gws-auth` skill. | Get file info | `gws drive info ` | | Download a file | `gws drive download ` | | Upload a file | `gws drive upload report.pdf` | -| Upload to folder | `gws drive upload data.xlsx --folder ` | | Create a folder | `gws drive create-folder --name "Project Files"` | | Move a file | `gws drive move --to ` | | Delete a file | `gws drive delete ` | -| Permanently delete | `gws drive delete --permanent` | | Copy a file | `gws drive copy ` | +| Export file | `gws drive export --file-id --mime-type application/pdf --output report.pdf` | +| Update metadata | `gws drive update --file-id --name "New Name"` | +| Empty trash | `gws drive empty-trash` | +| Drive info | `gws drive about` | +| Recent changes | `gws drive changes` | + +### Permissions +| Task | Command | +|------|---------| +| List permissions | `gws drive permissions --file-id ` | +| Share with user | `gws drive share --file-id --type user --role writer --email user@example.com` | +| Share with anyone | `gws drive share --file-id --type anyone --role reader` | +| Get permission | `gws drive permission --file-id --permission-id ` | +| Update permission | `gws drive update-permission --file-id --permission-id --role reader` | +| Remove permission | `gws drive unshare --file-id --permission-id ` | + +### Comments & Replies +| Task | Command | +|------|---------| | List comments | `gws drive comments ` | +| Get comment | `gws drive comment --file-id --comment-id ` | +| Add comment | `gws drive add-comment --file-id --content "Great work!"` | +| Delete comment | `gws drive delete-comment --file-id --comment-id ` | +| List replies | `gws drive replies --file-id --comment-id ` | +| Reply to comment | `gws drive reply --file-id --comment-id --content "Thanks!"` | +| Get reply | `gws drive get-reply --file-id --comment-id --reply-id ` | +| Delete reply | `gws drive delete-reply --file-id --comment-id --reply-id ` | + +### Revisions +| Task | Command | +|------|---------| +| List revisions | `gws drive revisions --file-id ` | +| Get revision | `gws drive revision --file-id --revision-id ` | +| Delete revision | `gws drive delete-revision --file-id --revision-id ` | + +### Shared Drives +| Task | Command | +|------|---------| +| List shared drives | `gws drive shared-drives` | +| Get shared drive | `gws drive shared-drive --id ` | +| Create shared drive | `gws drive create-drive --name "Engineering"` | +| Update shared drive | `gws drive update-drive --id --name "New Name"` | +| Delete shared drive | `gws drive delete-drive --id ` | ## Detailed Usage @@ -115,13 +156,6 @@ gws drive upload [flags] - `--name string` — File name in Drive (default: local filename) - `--mime-type string` — MIME type (auto-detected if not specified) -**Examples:** -```bash -gws drive upload report.pdf -gws drive upload data.xlsx --folder 1abc123xyz -gws drive upload document.docx --name "My Report" -``` - ### create-folder — Create a new folder ```bash @@ -132,80 +166,223 @@ gws drive create-folder --name [flags] - `--name string` — Folder name (required) - `--parent string` — Parent folder ID (default: root) -**Examples:** +### move — Move a file + ```bash -gws drive create-folder --name "Project Files" -gws drive create-folder --name "Subproject" --parent 1abc123xyz +gws drive move --to ``` -### move — Move a file +### delete — Delete a file ```bash -gws drive move --to +gws drive delete [flags] ``` -**Flags:** -- `--to string` — Destination folder ID (required) +By default, moves to trash. Use `--permanent` to permanently delete. + +### copy — Copy a file -**Examples:** ```bash -gws drive move 1abc123xyz --to 2def456uvw -gws drive move 1abc123xyz --to root +gws drive copy [flags] ``` -### delete — Delete a file +**Flags:** +- `--name string` — Name for the copy +- `--folder string` — Destination folder ID + +### export — Export a Google Workspace file ```bash -gws drive delete [flags] +gws drive export --file-id --mime-type --output ``` -By default, moves the file to trash. Use `--permanent` to permanently delete. +Exports Docs, Sheets, Slides to formats like PDF, CSV, DOCX, etc. **Flags:** -- `--permanent` — Permanently delete (skip trash) +- `--file-id string` — File ID (required) +- `--mime-type string` — Export MIME type (required, e.g. `application/pdf`, `text/csv`) +- `--output string` — Output file path (required) + +### update — Update file metadata -**Examples:** ```bash -gws drive delete 1abc123xyz -gws drive delete 1abc123xyz --permanent +gws drive update --file-id [flags] ``` -### copy — Copy a file +**Flags:** +- `--file-id string` — File ID (required) +- `--name string` — New file name +- `--description string` — New description +- `--starred` — Star or unstar the file +- `--trashed` — Trash or untrash the file + +### empty-trash — Empty trash ```bash -gws drive copy [flags] +gws drive empty-trash +``` + +Permanently deletes all files in the trash. Cannot be undone. + +### about — Drive storage and user info + +```bash +gws drive about ``` -Creates a copy of a file in Google Drive. Useful for duplicating template files (Docs, Sheets, Slides). +Returns user info and storage quota (limit, usage, usage in Drive, usage in trash). + +### changes — List recent file changes + +```bash +gws drive changes [flags] +``` **Flags:** -- `--name string` — Name for the copy (default: "Copy of ") -- `--folder string` — Destination folder ID +- `--max int` — Maximum number of changes (default 100) +- `--page-token string` — Page token (auto-fetches start token if empty) + +### permissions — List permissions -**Examples:** ```bash -gws drive copy 1abc123xyz -gws drive copy 1abc123xyz --name "Q1 OKR Deck" -gws drive copy 1abc123xyz --name "Project Plan" --folder 2def456uvw +gws drive permissions --file-id ``` -### comments — List comments on a file +### share — Share a file ```bash -gws drive comments [flags] +gws drive share --file-id --type --role [flags] +``` + +**Flags:** +- `--file-id string` — File ID (required) +- `--type string` — Permission type: `user`, `group`, `domain`, `anyone` (required) +- `--role string` — Role: `reader`, `commenter`, `writer`, `organizer`, `owner` (required) +- `--email string` — Email address (for user/group type) +- `--domain string` — Domain (for domain type) +- `--send-notification` — Send notification email (default: true) + +### unshare — Remove a permission + +```bash +gws drive unshare --file-id --permission-id +``` + +### permission — Get permission details + +```bash +gws drive permission --file-id --permission-id +``` + +### update-permission — Update a permission + +```bash +gws drive update-permission --file-id --permission-id --role ``` -Lists all comments and replies on a Google Drive file (works with Docs, Sheets, Slides). +### comments — List comments + +```bash +gws drive comments [flags] +``` **Flags:** - `--max int` — Maximum number of comments (default 100) - `--include-resolved` — Include resolved comments - `--include-deleted` — Include deleted comments -**Examples:** +### comment — Get a single comment + +```bash +gws drive comment --file-id --comment-id +``` + +### add-comment — Add a comment + +```bash +gws drive add-comment --file-id --content "comment text" +``` + +### delete-comment — Delete a comment + +```bash +gws drive delete-comment --file-id --comment-id +``` + +### replies — List replies + +```bash +gws drive replies --file-id --comment-id +``` + +### reply — Create a reply + +```bash +gws drive reply --file-id --comment-id --content "reply text" +``` + +### get-reply — Get a reply + +```bash +gws drive get-reply --file-id --comment-id --reply-id +``` + +### delete-reply — Delete a reply + +```bash +gws drive delete-reply --file-id --comment-id --reply-id +``` + +### revisions — List revisions + +```bash +gws drive revisions --file-id +``` + +### revision — Get revision details + +```bash +gws drive revision --file-id --revision-id +``` + +### delete-revision — Delete a revision + +```bash +gws drive delete-revision --file-id --revision-id +``` + +### shared-drives — List shared drives + +```bash +gws drive shared-drives [flags] +``` + +**Flags:** +- `--max int` — Maximum number of drives (default 100) +- `--query string` — Search query + +### shared-drive — Get shared drive info + +```bash +gws drive shared-drive --id +``` + +### create-drive — Create a shared drive + +```bash +gws drive create-drive --name "Drive Name" +``` + +### update-drive — Update a shared drive + +```bash +gws drive update-drive --id --name "New Name" +``` + +### delete-drive — Delete a shared drive + ```bash -gws drive comments 1abc123xyz -gws drive comments 1abc123xyz --include-resolved +gws drive delete-drive --id ``` ## Output Modes @@ -225,3 +402,7 @@ gws drive list --format text # Human-readable text - When uploading, MIME type is auto-detected from the file extension - The `comments` command works on any Drive file type (Docs, Sheets, Slides, etc.) - Resolved comments are excluded by default; use `--include-resolved` to see them +- Use `gws drive about` to check storage quota before large uploads +- Use `gws drive changes` to monitor recent file activity +- For sharing, use `--type anyone --role reader` for public access +- For Workspace file exports, common MIME types: `application/pdf`, `text/csv`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` diff --git a/skills/drive/references/commands.md b/skills/drive/references/commands.md index c1a1955..3dd3cb9 100644 --- a/skills/drive/references/commands.md +++ b/skills/drive/references/commands.md @@ -49,8 +49,6 @@ Usage: gws drive search [flags] |------|------|---------|-------------| | `--max` | int | 50 | Maximum number of results | -The query is a full-text search across file names and content. - --- ## gws drive info @@ -63,17 +61,6 @@ Usage: gws drive info No additional flags. -### Output Fields (JSON) - -- `id` — File ID -- `name` — File name -- `mimeType` — MIME type -- `size` — File size in bytes -- `createdTime` — Creation time -- `modifiedTime` — Last modified time -- `owners` — File owners -- `webViewLink` — Link to view in browser - --- ## gws drive download @@ -104,10 +91,6 @@ Usage: gws drive upload [flags] | `--name` | string | local filename | File name in Drive | | `--mime-type` | string | auto-detected | MIME type | -### MIME Type Auto-Detection - -The MIME type is auto-detected from the file extension. Override with `--mime-type` if needed. - --- ## gws drive create-folder @@ -137,8 +120,6 @@ Usage: gws drive move [flags] |------|------|---------|----------|-------------| | `--to` | string | | Yes | Destination folder ID | -Use `root` as the folder ID to move to the root of Drive. - --- ## gws drive delete @@ -153,8 +134,6 @@ Usage: gws drive delete [flags] |------|------|---------|-------------| | `--permanent` | bool | false | Permanently delete (skip trash) | -By default, files are moved to trash (recoverable for 30 days). Use `--permanent` only when explicitly intended. - --- ## gws drive copy @@ -170,8 +149,6 @@ Usage: gws drive copy [flags] | `--name` | string | "Copy of " | Name for the copy | | `--folder` | string | same as original | Destination folder ID | -Useful for duplicating template files (Docs, Sheets, Slides, etc.). - --- ## gws drive comments @@ -188,9 +165,392 @@ Usage: gws drive comments [flags] | `--include-resolved` | bool | false | Include resolved comments | | `--include-deleted` | bool | false | Include deleted comments | -### Notes +--- + +## gws drive export + +Exports a Google Workspace file to a specified format. + +``` +Usage: gws drive export [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--mime-type` | string | | Yes | Export MIME type (e.g. `application/pdf`) | +| `--output` | string | | Yes | Output file path | + +### Common Export MIME Types + +| Format | MIME Type | +|--------|-----------| +| PDF | `application/pdf` | +| CSV | `text/csv` | +| DOCX | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | +| XLSX | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | +| PPTX | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | +| Plain Text | `text/plain` | +| HTML | `text/html` | + +--- + +## gws drive empty-trash + +Permanently deletes all files in the trash. Cannot be undone. + +``` +Usage: gws drive empty-trash +``` + +No flags. + +--- + +## gws drive update + +Updates metadata of a file in Google Drive. + +``` +Usage: gws drive update [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--name` | string | | No | New file name | +| `--description` | string | | No | New description | +| `--starred` | bool | false | No | Star or unstar the file | +| `--trashed` | bool | false | No | Trash or untrash the file | + +--- + +## gws drive about + +Gets information about the user's Drive storage quota and account. + +``` +Usage: gws drive about +``` + +No flags. Returns user info and storage quota (limit, usage, usage in Drive, usage in trash). + +--- + +## gws drive changes + +Lists recent changes to files in Google Drive. + +``` +Usage: gws drive changes [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--max` | int | 100 | Maximum number of changes | +| `--page-token` | string | auto-fetched | Page token for pagination | + +If no page token is provided, the start page token is fetched automatically. + +--- + +## gws drive permissions + +Lists all permissions on a file. + +``` +Usage: gws drive permissions [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | + +--- + +## gws drive share + +Shares a file with a user, group, domain, or anyone. + +``` +Usage: gws drive share [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--type` | string | | Yes | Permission type: `user`, `group`, `domain`, `anyone` | +| `--role` | string | | Yes | Role: `reader`, `commenter`, `writer`, `organizer`, `owner` | +| `--email` | string | | No | Email address (for user/group type) | +| `--domain` | string | | No | Domain (for domain type) | +| `--send-notification` | bool | true | No | Send notification email | + +--- + +## gws drive unshare + +Removes a permission from a file. + +``` +Usage: gws drive unshare [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--permission-id` | string | | Yes | Permission ID | + +--- + +## gws drive permission + +Gets details of a specific permission. + +``` +Usage: gws drive permission [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--permission-id` | string | | Yes | Permission ID | + +--- + +## gws drive update-permission + +Updates the role of an existing permission. + +``` +Usage: gws drive update-permission [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--permission-id` | string | | Yes | Permission ID | +| `--role` | string | | Yes | New role | + +--- + +## gws drive comment + +Gets a specific comment on a file. + +``` +Usage: gws drive comment [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | + +--- + +## gws drive add-comment + +Adds a comment to a file. + +``` +Usage: gws drive add-comment [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--content` | string | | Yes | Comment content | + +--- + +## gws drive delete-comment -- Works on any Drive file type: Docs, Sheets, Slides, PDFs, etc. -- Resolved comments are excluded by default -- When filtering resolved comments, the actual result count may be less than `--max` since filtering happens after fetching from the API -- Each comment includes its replies +Deletes a comment from a file. + +``` +Usage: gws drive delete-comment [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | + +--- + +## gws drive replies + +Lists all replies to a comment. + +``` +Usage: gws drive replies [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | + +--- + +## gws drive reply + +Creates a reply to a comment. + +``` +Usage: gws drive reply [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | +| `--content` | string | | Yes | Reply content | + +--- + +## gws drive get-reply + +Gets a specific reply. + +``` +Usage: gws drive get-reply [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | +| `--reply-id` | string | | Yes | Reply ID | + +--- + +## gws drive delete-reply + +Deletes a reply. + +``` +Usage: gws drive delete-reply [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--comment-id` | string | | Yes | Comment ID | +| `--reply-id` | string | | Yes | Reply ID | + +--- + +## gws drive revisions + +Lists all revisions of a file. + +``` +Usage: gws drive revisions [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | + +--- + +## gws drive revision + +Gets details of a specific revision. + +``` +Usage: gws drive revision [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--revision-id` | string | | Yes | Revision ID | + +--- + +## gws drive delete-revision + +Deletes a specific revision. + +``` +Usage: gws drive delete-revision [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file-id` | string | | Yes | File ID | +| `--revision-id` | string | | Yes | Revision ID | + +--- + +## gws drive shared-drives + +Lists all shared drives. + +``` +Usage: gws drive shared-drives [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--max` | int | 100 | Maximum number of shared drives | +| `--query` | string | | Search query | + +--- + +## gws drive shared-drive + +Gets information about a shared drive. + +``` +Usage: gws drive shared-drive [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Shared drive ID | + +--- + +## gws drive create-drive + +Creates a new shared drive. + +``` +Usage: gws drive create-drive [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--name` | string | | Yes | Shared drive name | + +--- + +## gws drive delete-drive + +Deletes a shared drive. + +``` +Usage: gws drive delete-drive [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Shared drive ID | + +--- + +## gws drive update-drive + +Updates a shared drive. + +``` +Usage: gws drive update-drive [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Shared drive ID | +| `--name` | string | | No | New name for the shared drive | From 080b84a577f9777a10ecc33c987b727a9bd569f9 Mon Sep 17 00:00:00 2001 From: Omri Ariav Date: Wed, 18 Feb 2026 23:03:34 +0200 Subject: [PATCH 2/2] fix(drive): gofmt, export cleanup, changes help text - Fix gofmt alignment in drive_test.go Permission struct literal - Clean up partial output file on export io.Copy failure - Clarify changes command polling pattern in help and SKILL.md Co-Authored-By: Claude Opus 4.6 --- cmd/drive.go | 7 ++++++- cmd/drive_test.go | 6 +++--- skills/drive/SKILL.md | 6 +++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/drive.go b/cmd/drive.go index 640e418..767599f 100644 --- a/cmd/drive.go +++ b/cmd/drive.go @@ -333,7 +333,10 @@ var driveChangesCmd = &cobra.Command{ Short: "List recent file changes", Long: `Lists recent changes to files in Google Drive. -If no page token is provided, fetches the start page token automatically.`, +If no --page-token is provided, the current start token is fetched automatically. +The first call will typically return zero results because the token represents "now"; +save the returned new_start_page_token and pass it in subsequent calls to poll for +new changes (standard Drive changes polling pattern).`, RunE: runDriveChanges, } @@ -1956,6 +1959,8 @@ func runDriveExport(cmd *cobra.Command, args []string) error { written, err := io.Copy(outFile, exportResp.Body) if err != nil { + outFile.Close() + os.Remove(outputPath) return p.PrintError(fmt.Errorf("failed to write file: %w", err)) } diff --git a/cmd/drive_test.go b/cmd/drive_test.go index 02c87bd..fc7acf0 100644 --- a/cmd/drive_test.go +++ b/cmd/drive_test.go @@ -978,9 +978,9 @@ func TestDrivePermissions_MockServer(t *testing.T) { DisplayName: "Test User", }, { - Id: "perm-2", - Type: "anyone", - Role: "reader", + Id: "perm-2", + Type: "anyone", + Role: "reader", }, }, } diff --git a/skills/drive/SKILL.md b/skills/drive/SKILL.md index 335fa67..09410f1 100644 --- a/skills/drive/SKILL.md +++ b/skills/drive/SKILL.md @@ -238,9 +238,13 @@ Returns user info and storage quota (limit, usage, usage in Drive, usage in tras gws drive changes [flags] ``` +Polling pattern: the first call (without `--page-token`) fetches the current start token +and typically returns zero results. Save the returned `new_start_page_token` and pass it +in subsequent calls to detect new changes. + **Flags:** - `--max int` — Maximum number of changes (default 100) -- `--page-token string` — Page token (auto-fetches start token if empty) +- `--page-token string` — Page token from a previous call (auto-fetches start token if empty) ### permissions — List permissions