drive

package
v1.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 7, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var DriveAddComment = common.Shortcut{
	Service:     "drive",
	Command:     "+add-comment",
	Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
	Risk:        "write",
	Scopes: []string{
		"docx:document:readonly",
		"docs:document.comment:create",
		"docs:document.comment:write_only",
	},
	AuthTypes: []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
		{Name: "content", Desc: "reply_elements JSON string", Required: true},
		{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
		{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
		{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		docRef, err := parseCommentDocRef(runtime.Str("doc"))
		if err != nil {
			return err
		}

		if _, err := parseCommentReplyElements(runtime.Str("content")); err != nil {
			return err
		}

		selection := runtime.Str("selection-with-ellipsis")
		blockID := strings.TrimSpace(runtime.Str("block-id"))
		if strings.TrimSpace(selection) != "" && blockID != "" {
			return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive")
		}
		if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") {
			return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id")
		}

		mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
		if mode == commentModeLocal && docRef.Kind == "doc" {
			return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
		}

		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		docRef, _ := parseCommentDocRef(runtime.Str("doc"))
		replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
		selection := runtime.Str("selection-with-ellipsis")
		blockID := strings.TrimSpace(runtime.Str("block-id"))
		mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)

		targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)

		createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
		commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
		if mode == commentModeLocal {
			commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
		}

		mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)

		dry := common.NewDryRunAPI()
		switch {
		case mode == commentModeFull && resolvedBy == "wiki":
			dry.Desc("2-step orchestration: resolve wiki -> create full comment")
		case mode == commentModeFull:
			dry.Desc("1-step request: create full comment")
		case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
			dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
		case resolvedBy == "wiki":
			dry.Desc("2-step orchestration: resolve wiki -> create local comment")
		case strings.TrimSpace(selection) != "":
			dry.Desc("2-step orchestration: locate block -> create local comment")
		default:
			dry.Desc("1-step request: create local comment with explicit block ID")
		}

		if resolvedBy == "wiki" {
			dry.GET("/open-apis/wiki/v2/spaces/get_node").
				Desc("[1] Resolve wiki node to target document").
				Params(map[string]interface{}{"token": docRef.Token})
		}

		if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
			step := "[1]"
			if resolvedBy == "wiki" {
				step = "[2]"
			}
			mcpArgs := map[string]interface{}{
				"doc_id":                  dryRunLocateDocRef(docRef),
				"limit":                   defaultLocateDocLimit,
				"selection_with_ellipsis": selection,
			}
			dry.POST(mcpEndpoint).
				Desc(step+" MCP tool: locate-doc").
				Body(map[string]interface{}{
					"method": "tools/call",
					"params": map[string]interface{}{
						"name":      "locate-doc",
						"arguments": mcpArgs,
					},
				}).
				Set("mcp_tool", "locate-doc").
				Set("args", mcpArgs)
		}

		step := "[1]"
		createDesc := "Create full comment"
		if mode == commentModeLocal {
			createDesc = "Create local comment"
			step = "[2]"
			if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
				step = "[3]"
			} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
				step = "[2]"
			} else {
				step = "[1]"
			}
		} else if resolvedBy == "wiki" {
			step = "[2]"
		}

		return dry.POST(createPath).
			Desc(step+" "+createDesc).
			Body(commentBody).
			Set("file_token", targetToken)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		selection := runtime.Str("selection-with-ellipsis")
		blockID := strings.TrimSpace(runtime.Str("block-id"))
		mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)

		target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), mode)
		if err != nil {
			return err
		}

		replyElements, err := parseCommentReplyElements(runtime.Str("content"))
		if err != nil {
			return err
		}

		var locateResult locateDocResult
		selectedMatch := 0
		if mode == commentModeLocal && blockID == "" {
			_, locateResult, err = locateDocumentSelection(runtime, target, selection, defaultLocateDocLimit)
			if err != nil {
				return err
			}

			match, idx, err := selectLocateMatch(locateResult)
			if err != nil {
				return err
			}
			blockID = match.AnchorBlockID
			if strings.TrimSpace(blockID) == "" {
				return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
			}
			selectedMatch = idx
			fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID)
		} else if mode == commentModeLocal {
			fmt.Fprintf(runtime.IO().ErrOut, "Using explicit block ID: %s\n", blockID)
		}

		requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
		requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
		if mode == commentModeLocal {
			requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
		}

		if mode == commentModeLocal {
			fmt.Fprintf(runtime.IO().ErrOut, "Creating local comment in %s\n", common.MaskToken(target.FileToken))
		} else {
			fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken))
		}

		data, err := runtime.CallAPI(
			"POST",
			requestPath,
			nil,
			requestBody,
		)
		if err != nil {
			return err
		}

		out := map[string]interface{}{
			"comment_id":   data["comment_id"],
			"doc_id":       target.DocID,
			"file_token":   target.FileToken,
			"file_type":    target.FileType,
			"resolved_by":  target.ResolvedBy,
			"comment_mode": string(mode),
		}
		if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
			out["created_at"] = createdAt
		}
		if target.WikiToken != "" {
			out["wiki_token"] = target.WikiToken
		}
		if mode == commentModeLocal {
			out["anchor_block_id"] = blockID
			out["selection_source"] = "block_id"
			if strings.TrimSpace(selection) != "" {
				out["selection_source"] = "locate-doc"
				out["selection_with_ellipsis"] = selection
				out["match_count"] = locateResult.MatchCount
				out["match_index"] = selectedMatch
			}
		} else if isWhole, ok := data["is_whole"]; ok {
			out["is_whole"] = isWhole
		}

		runtime.Out(out, nil)
		return nil
	},
}
View Source
var DriveDownload = common.Shortcut{
	Service:     "drive",
	Command:     "+download",
	Description: "Download a file from Drive to local",
	Risk:        "read",
	Scopes:      []string{"drive:file:download"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file-token", Desc: "file token", Required: true},
		{Name: "output", Desc: "local save path"},
		{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		fileToken := runtime.Str("file-token")
		outputPath := runtime.Str("output")
		if outputPath == "" {
			outputPath = fileToken
		}
		return common.NewDryRunAPI().
			GET("/open-apis/drive/v1/files/:file_token/download").
			Set("file_token", fileToken).Set("output", outputPath)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		fileToken := runtime.Str("file-token")
		outputPath := runtime.Str("output")
		overwrite := runtime.Bool("overwrite")

		if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
			return output.ErrValidation("%s", err)
		}

		if outputPath == "" {
			outputPath = fileToken
		}
		safePath, err := validate.SafeOutputPath(outputPath)
		if err != nil {
			return output.ErrValidation("unsafe output path: %s", err)
		}
		if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
			return err
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))

		resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
			HttpMethod: http.MethodGet,
			ApiPath:    fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
		})
		if err != nil {
			return output.ErrNetwork("download failed: %s", err)
		}
		defer resp.Body.Close()

		if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
			return output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
		}

		sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
		if err != nil {
			return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
		}

		runtime.Out(map[string]interface{}{
			"saved_path": safePath,
			"size_bytes": sizeBytes,
		}, nil)
		return nil
	},
}
View Source
var DriveExport = common.Shortcut{
	Service:     "drive",
	Command:     "+export",
	Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
	Risk:        "read",
	Scopes: []string{
		"docs:document.content:read",
		"docs:document:export",
		"drive:drive.metadata:readonly",
	},
	AuthTypes: []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "token", Desc: "source document token", Required: true},
		{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
		{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown"}},
		{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
		{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
		{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateDriveExportSpec(driveExportSpec{
			Token:         runtime.Str("token"),
			DocType:       runtime.Str("doc-type"),
			FileExtension: runtime.Str("file-extension"),
			SubID:         runtime.Str("sub-id"),
		})
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		spec := driveExportSpec{
			Token:         runtime.Str("token"),
			DocType:       runtime.Str("doc-type"),
			FileExtension: runtime.Str("file-extension"),
			SubID:         runtime.Str("sub-id"),
		}

		if spec.FileExtension == "markdown" {
			return common.NewDryRunAPI().
				Desc("2-step orchestration: fetch docx markdown -> write local file").
				GET("/open-apis/docs/v1/content").
				Params(map[string]interface{}{
					"doc_token":    spec.Token,
					"doc_type":     "docx",
					"content_type": "markdown",
				})
		}

		body := map[string]interface{}{
			"token":          spec.Token,
			"type":           spec.DocType,
			"file_extension": spec.FileExtension,
		}
		if strings.TrimSpace(spec.SubID) != "" {
			body["sub_id"] = spec.SubID
		}

		return common.NewDryRunAPI().
			Desc("3-step orchestration: create export task -> limited polling -> download file").
			POST("/open-apis/drive/v1/export_tasks").
			Body(body)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		spec := driveExportSpec{
			Token:         runtime.Str("token"),
			DocType:       runtime.Str("doc-type"),
			FileExtension: runtime.Str("file-extension"),
			SubID:         runtime.Str("sub-id"),
		}
		outputDir := runtime.Str("output-dir")
		overwrite := runtime.Bool("overwrite")

		if spec.FileExtension == "markdown" {
			fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
			data, err := runtime.CallAPI(
				"GET",
				"/open-apis/docs/v1/content",
				map[string]interface{}{
					"doc_token":    spec.Token,
					"doc_type":     "docx",
					"content_type": "markdown",
				},
				nil,
			)
			if err != nil {
				return err
			}

			title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
			if err != nil {
				fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
				title = spec.Token
			}
			fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
			savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
			if err != nil {
				return err
			}

			runtime.Out(map[string]interface{}{
				"token":          spec.Token,
				"doc_type":       spec.DocType,
				"file_extension": spec.FileExtension,
				"file_name":      filepath.Base(savedPath),
				"saved_path":     savedPath,
				"size_bytes":     len([]byte(common.GetString(data, "content"))),
			}, nil)
			return nil
		}

		ticket, err := createDriveExportTask(runtime, spec)
		if err != nil {
			return err
		}
		fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)

		var lastStatus driveExportStatus
		var lastPollErr error
		hasObservedStatus := false

		for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
			if attempt > 1 {
				select {
				case <-ctx.Done():
					return ctx.Err()
				case <-time.After(driveExportPollInterval):
				}
			}
			if err := ctx.Err(); err != nil {
				return err
			}

			status, err := getDriveExportStatus(runtime, spec.Token, ticket)
			if err != nil {

				lastPollErr = err
				fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
				continue
			}
			lastStatus = status
			hasObservedStatus = true

			if status.Ready() {
				fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
				fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
				out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
				if err != nil {
					recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
					hint := fmt.Sprintf(
						"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
						ticket,
						status.FileToken,
						recoveryCommand,
					)
					var exitErr *output.ExitError
					if errors.As(err, &exitErr) && exitErr.Detail != nil {
						return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
					}
					return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
				}
				out["ticket"] = ticket
				out["doc_type"] = spec.DocType
				out["file_extension"] = spec.FileExtension
				runtime.Out(out, nil)
				return nil
			}

			if status.Failed() {
				msg := strings.TrimSpace(status.JobErrorMsg)
				if msg == "" {
					msg = status.StatusLabel()
				}
				return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
			}

			fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
		}

		nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
		if !hasObservedStatus && lastPollErr != nil {
			hint := fmt.Sprintf(
				"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
				ticket,
				nextCommand,
			)
			var exitErr *output.ExitError
			if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
				if strings.TrimSpace(exitErr.Detail.Hint) != "" {
					hint = exitErr.Detail.Hint + "\n" + hint
				}
				return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
			}
			return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
		}

		failed := false
		var jobStatus interface{}
		jobStatusLabel := "unknown"
		if hasObservedStatus {
			failed = lastStatus.Failed()
			jobStatus = lastStatus.JobStatus
			jobStatusLabel = lastStatus.StatusLabel()
		}

		runtime.Out(map[string]interface{}{
			"ticket":           ticket,
			"token":            spec.Token,
			"doc_type":         spec.DocType,
			"file_extension":   spec.FileExtension,
			"ready":            false,
			"failed":           failed,
			"job_status":       jobStatus,
			"job_status_label": jobStatusLabel,
			"timed_out":        true,
			"next_command":     nextCommand,
		}, nil)
		fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
		return nil
	},
}

DriveExport exports Drive-native documents to local files and falls back to a follow-up command when the async export task does not finish in time.

View Source
var DriveExportDownload = common.Shortcut{
	Service:     "drive",
	Command:     "+export-download",
	Description: "Download an exported file by file_token",
	Risk:        "read",
	Scopes: []string{
		"docs:document:export",
	},
	AuthTypes: []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file-token", Desc: "exported file token", Required: true},
		{Name: "file-name", Desc: "preferred output filename (optional)"},
		{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
		{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
			return output.ErrValidation("%s", err)
		}
		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		return common.NewDryRunAPI().
			GET("/open-apis/drive/v1/export_tasks/file/:file_token/download").
			Set("file_token", runtime.Str("file-token")).
			Set("output_dir", runtime.Str("output-dir"))
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

		out, err := downloadDriveExportFile(
			ctx,
			runtime,
			runtime.Str("file-token"),
			runtime.Str("output-dir"),
			runtime.Str("file-name"),
			runtime.Bool("overwrite"),
		)
		if err != nil {
			return err
		}
		runtime.Out(out, nil)
		return nil
	},
}

DriveExportDownload downloads an already-generated export artifact when the caller has a file token from a previous export task.

View Source
var DriveImport = common.Shortcut{
	Service:     "drive",
	Command:     "+import",
	Description: "Import a local file to Drive as a cloud document (docx, sheet, bitable)",
	Risk:        "write",
	Scopes: []string{
		"docs:document.media:upload",
		"docs:document:import",
	},
	AuthTypes: []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
		{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
		{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
		{Name: "name", Desc: "imported file name (default: local file name without extension)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateDriveImportSpec(driveImportSpec{
			FilePath:    runtime.Str("file"),
			DocType:     strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
			Name:        runtime.Str("name"),
		})
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		spec := driveImportSpec{
			FilePath:    runtime.Str("file"),
			DocType:     strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
			Name:        runtime.Str("name"),
		}
		fileSize, err := preflightDriveImportFile(&spec)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}

		dry := common.NewDryRunAPI()
		dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")

		appendDriveImportUploadDryRun(dry, spec, fileSize)

		dry.POST("/open-apis/drive/v1/import_tasks").
			Desc("[2] Create import task").
			Body(spec.CreateTaskBody("<file_token>"))

		dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
			Desc("[3] Poll import task result").
			Set("ticket", "<ticket>")

		return dry
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		spec := driveImportSpec{
			FilePath:    runtime.Str("file"),
			DocType:     strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
			Name:        runtime.Str("name"),
		}
		if _, err := preflightDriveImportFile(&spec); err != nil {
			return err
		}

		fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
		if uploadErr != nil {
			return uploadErr
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)

		ticket, err := createDriveImportTask(runtime, spec, fileToken)
		if err != nil {
			return err
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)

		status, ready, err := pollDriveImportTask(runtime, ticket)
		if err != nil {
			return err
		}

		resultType := status.DocType
		if resultType == "" {
			resultType = spec.DocType
		}
		out := map[string]interface{}{
			"ticket":           ticket,
			"type":             resultType,
			"ready":            ready,
			"job_status":       status.JobStatus,
			"job_status_label": status.StatusLabel(),
		}
		if status.Token != "" {
			out["token"] = status.Token
		}
		if status.URL != "" {
			out["url"] = status.URL
		}
		if status.JobErrorMsg != "" {
			out["job_error_msg"] = status.JobErrorMsg
		}
		if status.Extra != nil {
			out["extra"] = status.Extra
		}
		if !ready {
			nextCommand := driveImportTaskResultCommand(ticket)
			fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
			out["timed_out"] = true
			out["next_command"] = nextCommand
		}

		runtime.Out(out, nil)
		return nil
	},
}

DriveImport uploads a local file, creates an import task, and polls until the imported cloud document is ready or the local polling window expires.

View Source
var DriveMove = common.Shortcut{
	Service:     "drive",
	Command:     "+move",
	Description: "Move a file or folder to another location in Drive",
	Risk:        "write",
	Scopes:      []string{"space:document:move"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file-token", Desc: "file or folder token to move", Required: true},
		{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, slides)", Required: true},
		{Name: "folder-token", Desc: "target folder token (default: root folder)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateDriveMoveSpec(driveMoveSpec{
			FileToken:   runtime.Str("file-token"),
			FileType:    strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
		})
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		spec := driveMoveSpec{
			FileToken:   runtime.Str("file-token"),
			FileType:    strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
		}

		dry := common.NewDryRunAPI().
			Desc("Move file or folder in Drive")

		dry.POST("/open-apis/drive/v1/files/:file_token/move").
			Desc("[1] Move file/folder").
			Set("file_token", spec.FileToken).
			Body(spec.RequestBody())

		if spec.FileType == "folder" {
			dry.GET("/open-apis/drive/v1/files/task_check").
				Desc("[2] Poll async task status (for folder move)").
				Params(driveTaskCheckParams("<task_id>"))
		}

		return dry
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		spec := driveMoveSpec{
			FileToken:   runtime.Str("file-token"),
			FileType:    strings.ToLower(runtime.Str("type")),
			FolderToken: runtime.Str("folder-token"),
		}

		if spec.FolderToken == "" {
			fmt.Fprintf(runtime.IO().ErrOut, "No target folder specified, getting root folder...\n")
			rootToken, err := getRootFolderToken(ctx, runtime)
			if err != nil {
				return err
			}
			if rootToken == "" {
				return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
			}
			spec.FolderToken = rootToken
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))

		data, err := runtime.CallAPI(
			"POST",
			fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
			nil,
			spec.RequestBody(),
		)
		if err != nil {
			return err
		}

		if spec.FileType == "folder" {
			taskID := common.GetString(data, "task_id")
			if taskID == "" {
				return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
			}

			fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)

			status, ready, err := pollDriveTaskCheck(runtime, taskID)
			if err != nil {
				return err
			}

			out := map[string]interface{}{
				"task_id":      taskID,
				"status":       status.StatusLabel(),
				"file_token":   spec.FileToken,
				"folder_token": spec.FolderToken,
				"ready":        ready,
			}
			if !ready {
				nextCommand := driveTaskCheckResultCommand(taskID)
				fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
				out["timed_out"] = true
				out["next_command"] = nextCommand
			}

			runtime.Out(out, nil)
		} else {

			runtime.Out(map[string]interface{}{
				"file_token":   spec.FileToken,
				"folder_token": spec.FolderToken,
				"type":         spec.FileType,
			}, nil)
		}

		return nil
	},
}

DriveMove moves a Drive file or folder and handles the async task polling required by folder moves.

View Source
var DriveTaskResult = common.Shortcut{
	Service:     "drive",
	Command:     "+task_result",
	Description: "Poll async task result for import, export, move, or delete operations",
	Risk:        "read",
	Scopes:      []string{"drive:drive.metadata:readonly"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
		{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
		{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
		{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		scenario := strings.ToLower(runtime.Str("scenario"))
		validScenarios := map[string]bool{
			"import":     true,
			"export":     true,
			"task_check": true,
		}
		if !validScenarios[scenario] {
			return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
		}

		switch scenario {
		case "import", "export":
			if runtime.Str("ticket") == "" {
				return output.ErrValidation("--ticket is required for %s scenario", scenario)
			}
			if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
				return output.ErrValidation("%s", err)
			}
		case "task_check":
			if runtime.Str("task-id") == "" {
				return output.ErrValidation("--task-id is required for task_check scenario")
			}
			if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
				return output.ErrValidation("%s", err)
			}
		}

		if scenario == "export" && runtime.Str("file-token") == "" {
			return output.ErrValidation("--file-token is required for export scenario")
		}
		if scenario == "export" {
			if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
				return output.ErrValidation("%s", err)
			}
		}

		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		scenario := strings.ToLower(runtime.Str("scenario"))
		ticket := runtime.Str("ticket")
		taskID := runtime.Str("task-id")
		fileToken := runtime.Str("file-token")

		dry := common.NewDryRunAPI()
		dry.Desc(fmt.Sprintf("Poll async task result for %s scenario", scenario))

		switch scenario {
		case "import":
			dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
				Desc("[1] Query import task result").
				Set("ticket", ticket)
		case "export":
			dry.GET("/open-apis/drive/v1/export_tasks/:ticket").
				Desc("[1] Query export task result").
				Set("ticket", ticket).
				Params(map[string]interface{}{"token": fileToken})
		case "task_check":
			dry.GET("/open-apis/drive/v1/files/task_check").
				Desc("[1] Query move/delete folder task status").
				Params(driveTaskCheckParams(taskID))
		}

		return dry
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		scenario := strings.ToLower(runtime.Str("scenario"))
		ticket := runtime.Str("ticket")
		taskID := runtime.Str("task-id")
		fileToken := runtime.Str("file-token")

		fmt.Fprintf(runtime.IO().ErrOut, "Querying %s task result...\n", scenario)

		var result map[string]interface{}
		var err error

		switch scenario {
		case "import":
			result, err = queryImportTask(runtime, ticket)
		case "export":
			result, err = queryExportTask(runtime, ticket, fileToken)
		case "task_check":
			result, err = queryTaskCheck(runtime, taskID)
		}

		if err != nil {
			return err
		}

		runtime.Out(result, nil)
		return nil
	},
}

DriveTaskResult exposes a unified read path for the async task types produced by Drive import, export, and folder move flows.

View Source
var DriveUpload = common.Shortcut{
	Service:     "drive",
	Command:     "+upload",
	Description: "Upload a local file to Drive",
	Risk:        "write",
	Scopes:      []string{"drive:file:upload"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
		{Name: "folder-token", Desc: "target folder token (default: root)"},
		{Name: "name", Desc: "uploaded file name (default: local file name)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		filePath := runtime.Str("file")
		folderToken := runtime.Str("folder-token")
		name := runtime.Str("name")
		fileName := name
		if fileName == "" {
			fileName = filepath.Base(filePath)
		}
		return common.NewDryRunAPI().
			Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
			POST("/open-apis/drive/v1/files/upload_all").
			Body(map[string]interface{}{
				"file_name":   fileName,
				"parent_type": "explorer",
				"parent_node": folderToken,
				"file":        "@" + filePath,
			})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		filePath := runtime.Str("file")
		folderToken := runtime.Str("folder-token")
		name := runtime.Str("name")

		safeFilePath, err := validate.SafeInputPath(filePath)
		if err != nil {
			return output.ErrValidation("unsafe file path: %s", err)
		}
		filePath = safeFilePath

		fileName := name
		if fileName == "" {
			fileName = filepath.Base(filePath)
		}

		info, err := vfs.Stat(filePath)
		if err != nil {
			return output.ErrValidation("cannot read file: %s", err)
		}
		fileSize := info.Size()

		fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(fileSize))

		var fileToken string
		if fileSize > maxDriveUploadFileSize {
			fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
			fileToken, err = uploadFileMultipart(ctx, runtime, filePath, fileName, folderToken, fileSize)
		} else {
			fileToken, err = uploadFileToDrive(ctx, runtime, filePath, fileName, folderToken, fileSize)
		}
		if err != nil {
			return err
		}

		runtime.Out(map[string]interface{}{
			"file_token": fileToken,
			"file_name":  fileName,
			"size":       fileSize,
		}, nil)
		return nil
	},
}

Functions

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all drive shortcuts.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL