diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go new file mode 100644 index 00000000..edffcb04 --- /dev/null +++ b/shortcuts/drive/drive_export.go @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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. +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"), + } + // Markdown export is a special case: docx markdown comes from docs content + // directly instead of the Drive export task API. + 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") + + // Markdown export bypasses the async export task and writes the fetched + // markdown content directly to disk. + 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 + } + + // Prefer the remote title for the exported file name, but still fall + // back to the token if metadata is empty. + 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 + // Keep the command responsive by polling for a bounded window. If the task + // is still running after that, return a resume command instead of blocking. + 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 { + // Treat polling failures as transient so short-lived backend hiccups + // do not immediately fail an otherwise healthy export task. + 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() + } + // Return the last observed status so callers can resume from a known task + // state instead of losing all progress information on timeout. + 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 + }, +} diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go new file mode 100644 index 00000000..02707a6c --- /dev/null +++ b/shortcuts/drive/drive_export_common.go @@ -0,0 +1,371 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + driveExportPollAttempts = 10 + driveExportPollInterval = 5 * time.Second +) + +// driveExportSpec contains the normalized export request understood by the +// shortcut and the underlying export task APIs. +type driveExportSpec struct { + Token string + DocType string + FileExtension string + SubID string +} + +// driveExportTaskResultCommand prints the resume command shown when bounded +// export polling times out locally. +func driveExportTaskResultCommand(ticket, docToken string) string { + return fmt.Sprintf("lark-cli drive +task_result --scenario export --ticket %s --file-token %s", ticket, docToken) +} + +// driveExportDownloadCommand prints a copy-pasteable follow-up command for +// downloading an already-generated export artifact by file token. +func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string { + parts := []string{ + "lark-cli", "drive", "+export-download", + "--file-token", strconv.Quote(fileToken), + } + if strings.TrimSpace(fileName) != "" { + parts = append(parts, "--file-name", strconv.Quote(fileName)) + } + if strings.TrimSpace(outputDir) != "" && outputDir != "." { + parts = append(parts, "--output-dir", strconv.Quote(outputDir)) + } + if overwrite { + parts = append(parts, "--overwrite") + } + return strings.Join(parts, " ") +} + +// driveExportStatus captures the fields needed to decide whether the export is +// ready for download, still pending, or terminally failed. +type driveExportStatus struct { + Ticket string + FileExtension string + DocType string + FileName string + FileToken string + JobErrorMsg string + FileSize int64 + JobStatus int +} + +func (s driveExportStatus) Ready() bool { + return s.FileToken != "" && s.JobStatus == 0 +} + +func (s driveExportStatus) Pending() bool { + // A zero status without a file token is still in progress because there is + // nothing downloadable yet. + return s.JobStatus == 1 || s.JobStatus == 2 || s.JobStatus == 0 && s.FileToken == "" +} + +func (s driveExportStatus) Failed() bool { + return !s.Ready() && !s.Pending() && s.JobStatus != 0 +} + +func (s driveExportStatus) StatusLabel() string { + switch s.JobStatus { + case 0: + // Success is a special case where the file token is set. + if s.FileToken != "" { + return "success" + } + return "pending" + case 1: + return "new" + case 2: + return "processing" + case 3: + return "internal_error" + case 107: + return "export_size_limit" + case 108: + return "timeout" + case 109: + return "export_block_not_permitted" + case 110: + return "no_permission" + case 111: + return "docs_deleted" + case 122: + return "export_denied_on_copying" + case 123: + return "docs_not_exist" + case 6000: + return "export_images_exceed_limit" + default: + return fmt.Sprintf("status_%d", s.JobStatus) + } +} + +// validateDriveExportSpec enforces shortcut-level export constraints before any +// backend request is sent. +func validateDriveExportSpec(spec driveExportSpec) error { + if err := validate.ResourceName(spec.Token, "--token"); err != nil { + return output.ErrValidation("%s", err) + } + + switch spec.DocType { + case "doc", "docx", "sheet", "bitable": + default: + return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType) + } + + switch spec.FileExtension { + case "docx", "pdf", "xlsx", "csv", "markdown": + default: + return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown", spec.FileExtension) + } + + if spec.FileExtension == "markdown" && spec.DocType != "docx" { + return output.ErrValidation("--file-extension markdown only supports --doc-type docx") + } + + if strings.TrimSpace(spec.SubID) != "" { + if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") { + return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv") + } + if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil { + return output.ErrValidation("%s", err) + } + } + + if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" { + return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv") + } + + return nil +} + +// createDriveExportTask starts the asynchronous export job and returns its +// ticket for subsequent polling. +func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) { + body := map[string]interface{}{ + "token": spec.Token, + "type": spec.DocType, + "file_extension": spec.FileExtension, + } + if strings.TrimSpace(spec.SubID) != "" { + body["sub_id"] = spec.SubID + } + + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + if err != nil { + return "", err + } + + ticket := common.GetString(data, "ticket") + if ticket == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing") + } + return ticket, nil +} + +// getDriveExportStatus fetches the current backend state for a previously +// created export task. +func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return driveExportStatus{}, err + } + return parseDriveExportStatus(ticket, data), nil +} + +// parseDriveExportStatus accepts the wrapped export result and normalizes the +// subset of fields used by the shortcut. +func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExportStatus { + result := common.GetMap(data, "result") + status := driveExportStatus{ + Ticket: ticket, + } + if result == nil { + // Keep the ticket even when the result body is missing so callers can + // still show a resumable task reference. + return status + } + + status.FileExtension = common.GetString(result, "file_extension") + status.DocType = common.GetString(result, "type") + status.FileName = common.GetString(result, "file_name") + status.FileToken = common.GetString(result, "file_token") + status.JobErrorMsg = common.GetString(result, "job_error_msg") + status.FileSize = int64(common.GetFloat(result, "file_size")) + status.JobStatus = int(common.GetFloat(result, "job_status")) + return status +} + +// fetchDriveMetaTitle looks up the document title so exported files can use a +// human-readable default name when possible. +func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) { + data, err := runtime.CallAPI( + "POST", + "/open-apis/drive/v1/metas/batch_query", + nil, + map[string]interface{}{ + "request_docs": []map[string]interface{}{ + { + "doc_token": token, + "doc_type": docType, + }, + }, + }, + ) + if err != nil { + return "", err + } + + metas := common.GetSlice(data, "metas") + if len(metas) == 0 { + return "", nil + } + meta, _ := metas[0].(map[string]interface{}) + return common.GetString(meta, "title"), nil +} + +// saveContentToOutputDir validates the target path, enforces overwrite policy, +// and writes the payload atomically to disk. +func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) { + if outputDir == "" { + outputDir = "." + } + + // Sanitize both the filename and the combined output path so caller-provided + // names cannot escape the requested output directory. + safeName := sanitizeExportFileName(fileName, "export.bin") + target := filepath.Join(outputDir, safeName) + safePath, err := validate.SafeOutputPath(target) + if err != nil { + return "", output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return "", err + } + + if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { + return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err) + } + if err := validate.AtomicWrite(safePath, payload, 0644); err != nil { + return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err) + } + return safePath, nil +} + +// downloadDriveExportFile downloads the exported artifact, derives a safe local +// file name, and returns metadata about the saved file. +func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) { + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return nil, output.ErrValidation("%s", err) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return nil, output.ErrNetwork("download failed: %s", err) + } + if apiResp.StatusCode >= 400 { + return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + + fileName := strings.TrimSpace(preferredName) + if fileName == "" { + // Fall back to the server-provided download name when the caller did not + // request an explicit local file name. + fileName = client.ResolveFilename(apiResp) + } + savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "file_token": fileToken, + "file_name": filepath.Base(savedPath), + "saved_path": savedPath, + "size_bytes": len(apiResp.RawBody), + "content_type": apiResp.Header.Get("Content-Type"), + }, nil +} + +// sanitizeExportFileName strips path traversal and unsupported characters while +// preserving a readable file name when possible. +func sanitizeExportFileName(name, fallback string) string { + name = strings.TrimSpace(filepath.Base(name)) + if name == "" || name == "." || name == string(filepath.Separator) { + name = fallback + } + + replacer := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", + "\"", "_", "<", "_", ">", "_", "|", "_", + "\n", "_", "\r", "_", "\t", "_", "\x00", "_", + ) + name = replacer.Replace(name) + name = strings.Trim(name, ". ") + if name == "" { + return fallback + } + return name +} + +// ensureExportFileExtension appends the expected local suffix when the chosen +// file name does not already end with the export format's extension. +func ensureExportFileExtension(name, fileExtension string) string { + expected := exportFileSuffix(fileExtension) + if expected == "" { + return name + } + if strings.EqualFold(filepath.Ext(name), expected) { + return name + } + return name + expected +} + +// exportFileSuffix maps shortcut-level export formats to the local filename +// suffix written to disk. +func exportFileSuffix(fileExtension string) string { + switch fileExtension { + case "markdown": + return ".md" + case "docx": + return ".docx" + case "pdf": + return ".pdf" + case "xlsx": + return ".xlsx" + case "csv": + return ".csv" + default: + return "" + } +} diff --git a/shortcuts/drive/drive_export_common_test.go b/shortcuts/drive/drive_export_common_test.go new file mode 100644 index 00000000..39258cf6 --- /dev/null +++ b/shortcuts/drive/drive_export_common_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import "testing" + +func TestDriveExportStatusLabelCoversKnownAndUnknownCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status driveExportStatus + want string + }{ + { + name: "size limit", + status: driveExportStatus{JobStatus: 107}, + want: "export_size_limit", + }, + { + name: "not exist", + status: driveExportStatus{JobStatus: 123}, + want: "docs_not_exist", + }, + { + name: "unknown status", + status: driveExportStatus{JobStatus: 999}, + want: "status_999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.status.StatusLabel(); got != tt.want { + t.Fatalf("StatusLabel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseDriveExportStatusWithoutResultKeepsTicket(t *testing.T) { + t.Parallel() + + status := parseDriveExportStatus("ticket_export_test", map[string]interface{}{}) + if status.Ticket != "ticket_export_test" { + t.Fatalf("ticket = %q, want %q", status.Ticket, "ticket_export_test") + } + if status.FileToken != "" { + t.Fatalf("file token = %q, want empty", status.FileToken) + } +} + +func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) { + t.Parallel() + + if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" { + t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf") + } + if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" { + t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md") + } + if got := ensureExportFileExtension("report.pdf", "pdf"); got != "report.pdf" { + t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got) + } +} diff --git a/shortcuts/drive/drive_export_download.go b/shortcuts/drive/drive_export_download.go new file mode 100644 index 00000000..62ddd922 --- /dev/null +++ b/shortcuts/drive/drive_export_download.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// DriveExportDownload downloads an already-generated export artifact when the +// caller has a file token from a previous export task. +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 { + // Reuse the shared export download helper so overwrite checks, filename + // resolution, and output metadata stay consistent with drive +export. + 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 + }, +} diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go new file mode 100644 index 00000000..46ba0bfa --- /dev/null +++ b/shortcuts/drive/drive_export_test.go @@ -0,0 +1,516 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestValidateDriveExportSpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec driveExportSpec + wantErr string + }{ + { + name: "markdown docx ok", + spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"}, + }, + { + name: "markdown non docx rejected", + spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"}, + wantErr: "only supports --doc-type docx", + }, + { + name: "csv without sub id rejected", + spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "csv"}, + wantErr: "--sub-id is required", + }, + { + name: "sub id on non csv rejected", + spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pdf", SubID: "tbl_1"}, + wantErr: "--sub-id is only used", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateDriveExportSpec(tt.spec) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestDriveExportMarkdownWritesFile(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docs/v1/content", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "content": "# hello\n", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "metas": []map[string]interface{}{ + {"title": "Weekly Notes"}, + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "markdown", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("markdown content = %q", string(data)) + } + if !strings.Contains(stdout.String(), "Weekly Notes.md") { + t.Fatalf("stdout missing file name: %s", stdout.String()) + } +} + +func TestDriveExportAsyncSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_123"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "job_status": 0, + "file_token": "box_123", + "file_name": "report", + "file_extension": "pdf", + "type": "docx", + "file_size": 3, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/file/box_123/download", + Status: 200, + RawBody: []byte("pdf"), + Headers: http.Header{ + "Content-Type": []string{"application/pdf"}, + "Content-Disposition": []string{`attachment; filename="report.pdf"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval + driveExportPollAttempts, driveExportPollInterval = 1, 0 + t.Cleanup(func() { + driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "pdf", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "pdf" { + t.Fatalf("downloaded content = %q", string(data)) + } + if !strings.Contains(stdout.String(), `"ticket": "tk_123"`) { + t.Fatalf("stdout missing ticket: %s", stdout.String()) + } +} + +func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_ready"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_ready", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "job_status": 0, + "file_token": "box_ready", + "file_name": "report", + "file_extension": "pdf", + "type": "docx", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/file/box_ready/download", + Status: 200, + RawBody: []byte("pdf"), + Headers: http.Header{ + "Content-Type": []string{"application/pdf"}, + "Content-Disposition": []string{`attachment; filename="report.pdf"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval + driveExportPollAttempts, driveExportPollInterval = 1, 0 + t.Cleanup(func() { + driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "pdf", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected download recovery error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured exit error, got %v", err) + } + if !strings.Contains(exitErr.Detail.Message, "already exists") { + t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") { + t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + } + if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") { + t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint) + } + if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) { + t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + } +} + +func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_456"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_456", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "job_status": 2, + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval + driveExportPollAttempts, driveExportPollInterval = 1, 0 + t.Cleanup(func() { + driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "pdf", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"ticket": "tk_456"`) { + t.Fatalf("stdout missing ticket: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"timed_out": true`) { + t.Fatalf("stdout missing timed_out=true: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"failed": false`) { + t.Fatalf("stdout missing failed=false: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"job_status": 2`) { + t.Fatalf("stdout missing numeric job_status: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"job_status_label": "processing"`) { + t.Fatalf("stdout missing processing job_status_label: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"next_command": "lark-cli drive +task_result --scenario export --ticket tk_456 --file-token docx123"`) { + t.Fatalf("stdout missing follow-up command: %s", stdout.String()) + } + if _, err := os.Stat(filepath.Join(tmpDir, "report.pdf")); !os.IsNotExist(err) { + t.Fatalf("unexpected downloaded file, err=%v", err) + } +} + +func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_poll_fail"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_poll_fail", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "temporary backend failure", + }, + }) + + prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval + driveExportPollAttempts, driveExportPollInterval = 1, 0 + t.Cleanup(func() { + driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "pdf", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("expected persistent poll error, got nil") + } + if stdout.Len() != 0 { + t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String()) + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured exit error, got %v", err) + } + if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") { + t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") { + t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + } + if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") { + t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + } +} + +func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/file/box_789/download", + Status: 200, + RawBody: []byte("csv"), + Headers: http.Header{ + "Content-Type": []string{"text/csv"}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveExportDownload, []string{ + "+export-download", + "--file-token", "box_789", + "--file-name", "custom.csv", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "custom.csv")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "csv" { + t.Fatalf("downloaded content = %q", string(data)) + } +} + +func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download", + Status: 200, + RawBody: []byte("new"), + Headers: http.Header{ + "Content-Type": []string{"application/pdf"}, + "Content-Disposition": []string{`attachment; filename="dup.pdf"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("dup.pdf", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveExportDownload, []string{ + "+export-download", + "--file-token", "box_dup", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) { + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "exists.txt") + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir() error: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + _, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("expected overwrite error, got %v", err) + } +} + +func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_export", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "job_status": 2, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveTaskResult, []string{ + "+task_result", + "--scenario", "export", + "--ticket", "tk_export", + "--file-token", "docx123", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) { + t.Fatalf("stdout missing ready=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) { + t.Fatalf("stdout missing failed=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) { + t.Fatalf("stdout missing job_status_label: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go new file mode 100644 index 00000000..745be274 --- /dev/null +++ b/shortcuts/drive/drive_import.go @@ -0,0 +1,246 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// DriveImport uploads a local file, creates an import task, and polls until +// the imported cloud document is ready or the local polling window expires. +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)", 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"), + } + + dry := common.NewDryRunAPI() + dry.Desc("3-step orchestration: upload file -> create import task -> poll status") + + dry.POST("/open-apis/drive/v1/medias/upload_all"). + Desc("[1] Upload file to get file_token"). + Body(map[string]interface{}{ + "file_name": spec.SourceFileName(), + "parent_type": "ccm_import_open", + "size": "", + "extra": fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension()), + "file": "@" + spec.FilePath, + }) + + dry.POST("/open-apis/drive/v1/import_tasks"). + Desc("[2] Create import task"). + Body(spec.CreateTaskBody("")) + + dry.GET("/open-apis/drive/v1/import_tasks/:ticket"). + Desc("[3] Poll import task result"). + Set("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"), + } + + // Normalize and validate the local input path before opening the file. + safeFilePath, err := validate.SafeInputPath(spec.FilePath) + if err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + spec.FilePath = safeFilePath + + // Step 1: Upload file as media + 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) + + // Step 2: Create import task + ticket, err := createDriveImportTask(runtime, spec, fileToken) + if err != nil { + return err + } + + // Step 3: Poll task + fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket) + + status, ready, err := pollDriveImportTask(runtime, ticket) + if err != nil { + return err + } + + // Some intermediate responses omit the final type, so fall back to the + // requested type to keep the output shape stable. + 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 + }, +} + +// importTargetFileName returns the explicit import name when present, otherwise +// derives one from the local file name. +func importTargetFileName(filePath, explicitName string) string { + if explicitName != "" { + return explicitName + } + return importDefaultFileName(filePath) +} + +// importDefaultFileName strips only the last extension so names like +// "report.final.csv" become "report.final". +func importDefaultFileName(filePath string) string { + base := filepath.Base(filePath) + ext := filepath.Ext(base) + if ext == "" { + return base + } + name := strings.TrimSuffix(base, ext) + if name == "" { + return base + } + return name +} + +// uploadMediaForImport uploads the source file to the temporary import media +// endpoint and returns the file token consumed by import_tasks. +func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { + importInfo, err := os.Stat(filePath) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + fileSize := importInfo.Size() + if fileSize > maxDriveUploadFileSize { + return "", output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) + + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + extraMap := map[string]string{ + "obj_type": docType, + "file_extension": ext, + } + extraBytes, _ := json.Marshal(extraMap) + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "ccm_import_open") + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddField("extra", string(extraBytes)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + // Preserve already-classified CLI errors from lower layers instead of + // wrapping them as a generic network failure. + return "", err + } + return "", output.ErrNetwork("upload media failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: invalid response JSON: %v", err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + // Surface the backend error body so callers can see import-specific + // validation failures such as unsupported formats or permission issues. + msg, _ := result["msg"].(string) + return "", output.ErrAPI(larkCode, fmt.Sprintf("upload media failed: [%d] %s", larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: no file_token returned") + } + return fileToken, nil +} diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go new file mode 100644 index 00000000..34eb7bf7 --- /dev/null +++ b/shortcuts/drive/drive_import_common.go @@ -0,0 +1,263 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + driveImportPollAttempts = 30 + driveImportPollInterval = 2 * time.Second +) + +// driveImportExtToDocTypes defines which source file extensions can be imported +// into which Drive-native document types. +var driveImportExtToDocTypes = map[string][]string{ + "docx": {"docx"}, + "doc": {"docx"}, + "txt": {"docx"}, + "md": {"docx"}, + "mark": {"docx"}, + "markdown": {"docx"}, + "html": {"docx"}, + "xlsx": {"sheet", "bitable"}, + "xls": {"sheet", "bitable"}, + "csv": {"sheet", "bitable"}, +} + +// driveImportSpec contains the user-facing import inputs after normalization. +type driveImportSpec struct { + FilePath string + DocType string + FolderToken string + Name string +} + +func (s driveImportSpec) FileExtension() string { + return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".") +} + +func (s driveImportSpec) SourceFileName() string { + return filepath.Base(s.FilePath) +} + +func (s driveImportSpec) TargetFileName() string { + return importTargetFileName(s.FilePath, s.Name) +} + +// CreateTaskBody builds the request body expected by /drive/v1/import_tasks. +func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} { + return map[string]interface{}{ + "file_extension": s.FileExtension(), + "file_token": fileToken, + "type": s.DocType, + "file_name": s.TargetFileName(), + "point": map[string]interface{}{ + "mount_type": 1, + // The import API treats an empty mount_key as "use the caller's root + // folder", so preserve the zero value when --folder-token is omitted. + "mount_key": s.FolderToken, + }, + } +} + +// validateDriveImportSpec enforces the CLI-level compatibility rules before any +// upload or import request is sent to the backend. +func validateDriveImportSpec(spec driveImportSpec) error { + ext := spec.FileExtension() + if ext == "" { + return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx)") + } + + switch spec.DocType { + case "docx", "sheet", "bitable": + default: + return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable", spec.DocType) + } + + supportedTypes, ok := driveImportExtToDocTypes[ext] + if !ok { + return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv", ext) + } + + typeAllowed := false + // Validate the extension/type pair locally so users get a precise error + // before the file upload step. + for _, allowedType := range supportedTypes { + if allowedType == spec.DocType { + typeAllowed = true + break + } + } + if !typeAllowed { + var hint string + switch ext { + case "xlsx", "xls", "csv": + hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType) + default: + hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType) + } + return output.ErrValidation("file type mismatch: %s", hint) + } + + if strings.TrimSpace(spec.FolderToken) != "" { + if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + } + + return nil +} + +// driveImportStatus captures the backend fields needed to decide whether the +// import can be surfaced immediately or requires a follow-up poll. +type driveImportStatus struct { + Ticket string + DocType string + Token string + URL string + JobErrorMsg string + Extra interface{} + JobStatus int +} + +func (s driveImportStatus) Ready() bool { + return s.Token != "" && s.JobStatus == 0 +} + +func (s driveImportStatus) Pending() bool { + return s.JobStatus == 1 || s.JobStatus == 2 || (s.JobStatus == 0 && s.Token == "") +} + +func (s driveImportStatus) Failed() bool { + return !s.Ready() && !s.Pending() && s.JobStatus != 0 +} + +func (s driveImportStatus) StatusLabel() string { + switch s.JobStatus { + case 0: + // Some responses report status=0 before the imported token is materialized. + // Treat that intermediate state as pending rather than completed. + if s.Token == "" { + return "pending" + } + return "success" + case 1: + return "new" + case 2: + return "processing" + default: + return fmt.Sprintf("status_%d", s.JobStatus) + } +} + +// driveImportTaskResultCommand prints the resume command returned after bounded +// polling times out locally. +func driveImportTaskResultCommand(ticket string) string { + return fmt.Sprintf("lark-cli drive +task_result --scenario import --ticket %s", ticket) +} + +// createDriveImportTask creates the server-side import task after the media +// upload has produced a reusable file token. +func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) { + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken)) + if err != nil { + return "", err + } + + ticket := common.GetString(data, "ticket") + if ticket == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks") + } + return ticket, nil +} + +// getDriveImportStatus fetches the current state of an import task by ticket. +func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) { + if err := validate.ResourceName(ticket, "--ticket"); err != nil { + return driveImportStatus{}, output.ErrValidation("%s", err) + } + + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)), + nil, + nil, + ) + if err != nil { + return driveImportStatus{}, err + } + + return parseDriveImportStatus(ticket, data), nil +} + +// parseDriveImportStatus accepts either the wrapped API response or an already +// extracted result object to keep the helper easy to test. +func parseDriveImportStatus(ticket string, data map[string]interface{}) driveImportStatus { + result := common.GetMap(data, "result") + if result == nil { + // Some tests and helper call sites already pass the unwrapped result body. + result = data + } + + return driveImportStatus{ + Ticket: ticket, + DocType: common.GetString(result, "type"), + Token: common.GetString(result, "token"), + URL: common.GetString(result, "url"), + JobErrorMsg: common.GetString(result, "job_error_msg"), + Extra: result["extra"], + JobStatus: int(common.GetFloat(result, "job_status")), + } +} + +// pollDriveImportTask waits for the import to finish within a bounded window +// and returns the last observed status for resume-on-timeout flows. +func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveImportStatus, bool, error) { + lastStatus := driveImportStatus{Ticket: ticket} + var lastErr error + hadSuccessfulPoll := false + for attempt := 1; attempt <= driveImportPollAttempts; attempt++ { + if attempt > 1 { + time.Sleep(driveImportPollInterval) + } + + status, err := getDriveImportStatus(runtime, ticket) + if err != nil { + lastErr = err + // Log the error but continue polling. + fmt.Fprintf(runtime.IO().ErrOut, "Import status attempt %d/%d failed: %v\n", attempt, driveImportPollAttempts, err) + continue + } + lastStatus = status + hadSuccessfulPoll = true + + // Stop immediately on terminal states and otherwise return the last known + // status so the caller can expose a follow-up command on timeout. + if status.Ready() { + fmt.Fprintf(runtime.IO().ErrOut, "Import completed successfully.\n") + return status, true, nil + } + if status.Failed() { + msg := strings.TrimSpace(status.JobErrorMsg) + if msg == "" { + msg = status.StatusLabel() + } + return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg) + } + } + if !hadSuccessfulPoll && lastErr != nil { + return lastStatus, false, lastErr + } + + return lastStatus, false, nil +} diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go new file mode 100644 index 00000000..83ea1c1c --- /dev/null +++ b/shortcuts/drive/drive_import_common_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) { + t.Parallel() + + err := validateDriveImportSpec(driveImportSpec{ + FilePath: "./data.xlsx", + DocType: "docx", + }) + if err == nil || !strings.Contains(err.Error(), "file type mismatch") { + t.Fatalf("expected file type mismatch error, got %v", err) + } +} + +func TestParseDriveImportStatus(t *testing.T) { + t.Parallel() + + status := parseDriveImportStatus("tk_123", map[string]interface{}{ + "result": map[string]interface{}{ + "type": "sheet", + "job_status": 0, + "job_error_msg": "", + "token": "sheet_123", + "url": "https://example.com/sheets/sheet_123", + "extra": []interface{}{"2000"}, + }, + }) + + if !status.Ready() { + t.Fatal("expected import status to be ready") + } + if status.StatusLabel() != "success" { + t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success") + } + if status.Token != "sheet_123" { + t.Fatalf("token = %q, want %q", status.Token, "sheet_123") + } +} + +func TestDriveImportStatusPendingWithoutToken(t *testing.T) { + t.Parallel() + + status := driveImportStatus{JobStatus: 0} + if status.Ready() { + t.Fatal("expected status without token to be not ready") + } + if !status.Pending() { + t.Fatal("expected status without token to be pending") + } + if got := status.StatusLabel(); got != "pending" { + t.Fatalf("StatusLabel() = %q, want %q", got, "pending") + } +} + +func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_123"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/import_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_import"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/tk_import", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "type": "sheet", + "job_status": 2, + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval + driveImportPollAttempts, driveImportPollInterval = 1, 0 + t.Cleanup(func() { + driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "data.xlsx", + "--type", "sheet", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) { + t.Fatalf("stdout missing ready=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) { + t.Fatalf("stdout missing timed_out=true: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) { + t.Fatalf("stdout missing follow-up command: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go new file mode 100644 index 00000000..ee4cbea6 --- /dev/null +++ b/shortcuts/drive/drive_import_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestImportDefaultFileName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + want string + }{ + { + name: "strip xlsx extension", + filePath: "/tmp/base-import.xlsx", + want: "base-import", + }, + { + name: "strip last extension only", + filePath: "/tmp/report.final.csv", + want: "report.final", + }, + { + name: "keep name without extension", + filePath: "/tmp/README", + want: "README", + }, + { + name: "keep hidden file name when trim would be empty", + filePath: "/tmp/.env", + want: ".env", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := importDefaultFileName(tt.filePath); got != tt.want { + t.Fatalf("importDefaultFileName(%q) = %q, want %q", tt.filePath, got, tt.want) + } + }) + } +} + +func TestImportTargetFileName(t *testing.T) { + t.Parallel() + + if got := importTargetFileName("/tmp/base-import.xlsx", "custom-name.xlsx"); got != "custom-name.xlsx" { + t.Fatalf("explicit name should win, got %q", got) + } + if got := importTargetFileName("/tmp/base-import.xlsx", ""); got != "base-import" { + t.Fatalf("default import name = %q, want %q", got, "base-import") + } +} + +func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +import"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("type", "bitable"); err != nil { + t.Fatalf("set --type: %v", err) + } + if err := cmd.Flags().Set("folder-token", "fld_test"); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := DriveImport.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 3 { + t.Fatalf("expected 3 API calls, got %d", len(got.API)) + } + + uploadName, _ := got.API[0].Body["file_name"].(string) + if uploadName != "base-import.xlsx" { + t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx") + } + + importName, _ := got.API[1].Body["file_name"].(string) + if importName != "base-import" { + t.Fatalf("import task file_name = %q, want %q", importName, "base-import") + } +} + +func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) { + t.Parallel() + + spec := driveImportSpec{ + FilePath: "/tmp/README.md", + DocType: "docx", + } + + body := spec.CreateTaskBody("file_token_test") + point, ok := body["point"].(map[string]interface{}) + if !ok { + t.Fatalf("point = %#v, want map", body["point"]) + } + + raw, exists := point["mount_key"] + if !exists { + t.Fatal("mount_key missing; want empty string for root import") + } + got, ok := raw.(string) + if !ok { + t.Fatalf("mount_key type = %T, want string", raw) + } + if got != "" { + t.Fatalf("mount_key = %q, want empty string for root import", got) + } + + spec.FolderToken = "fld_test" + body = spec.CreateTaskBody("file_token_test") + point, ok = body["point"].(map[string]interface{}) + if !ok { + t.Fatalf("point = %#v, want map", body["point"]) + } + if got, _ := point["mount_key"].(string); got != "fld_test" { + t.Fatalf("mount_key = %q, want %q", got, "fld_test") + } +} diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index 66750d58..17f01ba2 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -5,9 +5,11 @@ package drive import ( "bytes" + "fmt" "net/http" "os" "strings" + "sync/atomic" "testing" "github.com/spf13/cobra" @@ -18,9 +20,11 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +var driveTestConfigSeq atomic.Int64 + func driveTestConfig() *core.CliConfig { return &core.CliConfig{ - AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + AppID: fmt.Sprintf("drive-test-app-%d", driveTestConfigSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu, } } diff --git a/shortcuts/drive/drive_move.go b/shortcuts/drive/drive_move.go new file mode 100644 index 00000000..2d2b5ed1 --- /dev/null +++ b/shortcuts/drive/drive_move.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// DriveMove moves a Drive file or folder and handles the async task polling +// required by folder moves. +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 moving a folder, show the async task check step + if spec.FileType == "folder" { + dry.GET("/open-apis/drive/v1/files/task_check"). + Desc("[2] Poll async task status (for folder move)"). + Params(driveTaskCheckParams("")) + } + + 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"), + } + + // Default to the caller's root folder so the command can move items + // without requiring an explicit destination in common cases. + 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 + } + + // Folder moves are asynchronous; file moves complete in the initial call. + 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 + } + + // Include both the source and destination identifiers so a timed-out + // folder move can be resumed or inspected without reconstructing inputs. + 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 { + // Non-folder moves are synchronous, so the initial request is the final + // outcome and no follow-up task metadata is needed. + runtime.Out(map[string]interface{}{ + "file_token": spec.FileToken, + "folder_token": spec.FolderToken, + "type": spec.FileType, + }, nil) + } + + return nil + }, +} + +// getRootFolderToken resolves the caller's Drive root folder token so other +// commands can safely use it as a default destination. +func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) { + data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil) + if err != nil { + return "", err + } + + token := common.GetString(data, "token") + if token == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token") + } + + return token, nil +} diff --git a/shortcuts/drive/drive_move_common.go b/shortcuts/drive/drive_move_common.go new file mode 100644 index 00000000..dfdaa0e6 --- /dev/null +++ b/shortcuts/drive/drive_move_common.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "fmt" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + driveMovePollAttempts = 30 + driveMovePollInterval = 2 * time.Second +) + +// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move +// endpoint that this shortcut wraps. +var driveMoveAllowedTypes = map[string]bool{ + "file": true, + "docx": true, + "bitable": true, + "doc": true, + "sheet": true, + "mindnote": true, + "folder": true, + "slides": true, +} + +// driveMoveSpec contains the normalized input needed to issue a move request. +type driveMoveSpec struct { + FileToken string + FileType string + FolderToken string +} + +func (s driveMoveSpec) RequestBody() map[string]interface{} { + return map[string]interface{}{ + "type": s.FileType, + "folder_token": s.FolderToken, + } +} + +func validateDriveMoveSpec(spec driveMoveSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if strings.TrimSpace(spec.FolderToken) != "" { + if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + } + if !driveMoveAllowedTypes[spec.FileType] { + return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType) + } + return nil +} + +// driveTaskCheckStatus represents the status payload returned by +// /drive/v1/files/task_check for async folder operations. +type driveTaskCheckStatus struct { + TaskID string + Status string +} + +func (s driveTaskCheckStatus) Ready() bool { + return strings.EqualFold(strings.TrimSpace(s.Status), "success") +} + +func (s driveTaskCheckStatus) Failed() bool { + return strings.EqualFold(strings.TrimSpace(s.Status), "failed") +} + +func (s driveTaskCheckStatus) Pending() bool { + return !s.Ready() && !s.Failed() +} + +func (s driveTaskCheckStatus) StatusLabel() string { + status := strings.TrimSpace(s.Status) + if status == "" { + // Empty status is treated as unknown so callers can still render a + // meaningful label instead of an empty string. + return "unknown" + } + return status +} + +// driveTaskCheckResultCommand prints the resume command shown when bounded +// polling ends before the backend task completes. +func driveTaskCheckResultCommand(taskID string) string { + return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID) +} + +// driveTaskCheckParams keeps the task_check query parameter shape in one place +// for both dry-run and execution paths. +func driveTaskCheckParams(taskID string) map[string]interface{} { + return map[string]interface{}{"task_id": taskID} +} + +// getDriveTaskCheckStatus fetches and validates the current state of an async +// folder move or delete task. +func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) { + if err := validate.ResourceName(taskID, "--task-id"); err != nil { + return driveTaskCheckStatus{}, output.ErrValidation("%s", err) + } + + data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil) + if err != nil { + return driveTaskCheckStatus{}, err + } + + return parseDriveTaskCheckStatus(taskID, data), nil +} + +// parseDriveTaskCheckStatus tolerates both wrapped and already-unwrapped +// response shapes used in tests and helpers. +func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) driveTaskCheckStatus { + result := common.GetMap(data, "result") + if result == nil { + result = data + } + + return driveTaskCheckStatus{ + TaskID: taskID, + Status: common.GetString(result, "status"), + } +} + +// pollDriveTaskCheck polls the backend for a bounded period and returns the +// last seen status so callers can emit a follow-up command when needed. +func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) { + lastStatus := driveTaskCheckStatus{TaskID: taskID} + for attempt := 1; attempt <= driveMovePollAttempts; attempt++ { + if attempt > 1 { + time.Sleep(driveMovePollInterval) + } + + status, err := getDriveTaskCheckStatus(runtime, taskID) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err) + continue + } + lastStatus = status + // Success and failure are terminal backend states. Any other value is kept + // as pending so the caller can decide whether to continue or resume later. + if status.Ready() { + fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n") + return status, true, nil + } + if status.Failed() { + return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed") + } + } + + return lastStatus, false, nil +} diff --git a/shortcuts/drive/drive_move_common_test.go b/shortcuts/drive/drive_move_common_test.go new file mode 100644 index 00000000..8221ada9 --- /dev/null +++ b/shortcuts/drive/drive_move_common_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestParseDriveTaskCheckStatusFallback(t *testing.T) { + t.Parallel() + + status := parseDriveTaskCheckStatus("task_123", map[string]interface{}{ + "status": "success", + }) + + if !status.Ready() { + t.Fatal("expected task check status to be ready") + } + if status.StatusLabel() != "success" { + t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success") + } +} + +func TestDriveTaskCheckStatusPendingAndUnknownLabel(t *testing.T) { + t.Parallel() + + status := driveTaskCheckStatus{} + if !status.Pending() { + t.Fatal("expected empty status to be treated as pending") + } + if got := status.StatusLabel(); got != "unknown" { + t.Fatalf("StatusLabel() = %q, want %q", got, "unknown") + } +} + +func TestValidateDriveMoveSpecRejectsUnsupportedType(t *testing.T) { + t.Parallel() + + err := validateDriveMoveSpec(driveMoveSpec{ + FileToken: "file_token_test", + FileType: "unsupported_type", + }) + if err == nil { + t.Fatal("expected unsupported type error, got nil") + } + if got := err.Error(); !bytes.Contains([]byte(got), []byte("unsupported file type")) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +move"} + cmd.Flags().String("file-token", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + if err := cmd.Flags().Set("file-token", "fld_src"); err != nil { + t.Fatalf("set --file-token: %v", err) + } + if err := cmd.Flags().Set("type", "folder"); err != nil { + t.Fatalf("set --type: %v", err) + } + if err := cmd.Flags().Set("folder-token", "fld_dst"); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := DriveMove.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(got.API)) + } + if got.API[1].Params["task_id"] != "" { + t.Fatalf("task check params = %#v", got.API[1].Params) + } +} + +func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/fld_src/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"task_id": "task_123"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/task_check", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"status": "success"}, + }, + }) + + prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval + driveMovePollAttempts, driveMovePollInterval = 1, 0 + t.Cleanup(func() { + driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveMove, []string{ + "+move", + "--file-token", "fld_src", + "--type", "folder", + "--folder-token", "fld_dst", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) { + t.Fatalf("stdout missing task id: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) { + t.Fatalf("stdout missing ready=true: %s", stdout.String()) + } +} + +func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/fld_src/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"task_id": "task_123"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/task_check", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"status": "pending"}, + }, + }) + + prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval + driveMovePollAttempts, driveMovePollInterval = 1, 0 + t.Cleanup(func() { + driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveMove, []string{ + "+move", + "--file-token", "fld_src", + "--type", "folder", + "--folder-token", "fld_dst", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) { + t.Fatalf("stdout missing ready=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) { + t.Fatalf("stdout missing timed_out=true: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) { + t.Fatalf("stdout missing follow-up command: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/drive_move_test.go b/shortcuts/drive/drive_move_test.go new file mode 100644 index 00000000..184b9e5e --- /dev/null +++ b/shortcuts/drive/drive_move_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/explorer/v2/root_folder/meta", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "token": "folder_root_token_test", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/file_token_test/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRunDrive(t, DriveMove, []string{ + "+move", + "--file-token", "file_token_test", + "--type", "file", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"folder_token": "folder_root_token_test"`) { + t.Fatalf("stdout missing resolved root folder token: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"file_token": "file_token_test"`) { + t.Fatalf("stdout missing file token: %s", stdout.String()) + } +} + +func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/explorer/v2/root_folder/meta", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRunDrive(t, DriveMove, []string{ + "+move", + "--file-token", "file_token_test", + "--type", "file", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected missing root folder token error, got nil") + } + if !strings.Contains(err.Error(), "root_folder/meta returned no token") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go new file mode 100644 index 00000000..7b52c3d3 --- /dev/null +++ b/shortcuts/drive/drive_task_result.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// DriveTaskResult exposes a unified read path for the async task types produced +// by Drive import, export, and folder move flows. +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) + } + + // Validate required params based on 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) + } + } + + // For export scenario, file-token is required + 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 + + // Each scenario maps to a different backend API, but this shortcut keeps + // the CLI surface uniform for resume-on-timeout workflows. + 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 + }, +} + +// queryImportTask returns a stable, shortcut-friendly view of the import task. +func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) { + status, err := getDriveImportStatus(runtime, ticket) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "scenario": "import", + "ticket": status.Ticket, + "type": status.DocType, + "ready": status.Ready(), + "failed": status.Failed(), + "job_status": status.JobStatus, + "job_status_label": status.StatusLabel(), + "job_error_msg": status.JobErrorMsg, + "token": status.Token, + "url": status.URL, + "extra": status.Extra, + }, nil +} + +// queryExportTask returns the export task status together with download metadata +// once the backend has produced the exported file. +func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (map[string]interface{}, error) { + status, err := getDriveExportStatus(runtime, fileToken, ticket) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "scenario": "export", + "ticket": status.Ticket, + "ready": status.Ready(), + "failed": status.Failed(), + "file_extension": status.FileExtension, + "type": status.DocType, + "file_name": status.FileName, + "file_token": status.FileToken, + "file_size": status.FileSize, + "job_error_msg": status.JobErrorMsg, + "job_status": status.JobStatus, + "job_status_label": status.StatusLabel(), + }, nil +} + +// queryTaskCheck returns the normalized status of a folder move/delete task. +func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { + status, err := getDriveTaskCheckStatus(runtime, taskID) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "scenario": "task_check", + "task_id": status.TaskID, + "status": status.StatusLabel(), + "ready": status.Ready(), + "failed": status.Failed(), + }, nil +} diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go new file mode 100644 index 00000000..cb11ec75 --- /dev/null +++ b/shortcuts/drive/drive_task_result_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags map[string]string + wantErr string + }{ + { + name: "unsupported scenario", + flags: map[string]string{ + "scenario": "unknown", + }, + wantErr: "unsupported scenario", + }, + { + name: "import missing ticket", + flags: map[string]string{ + "scenario": "import", + }, + wantErr: "--ticket is required", + }, + { + name: "export missing file token", + flags: map[string]string{ + "scenario": "export", + "ticket": "ticket_export_test", + }, + wantErr: "--file-token is required", + }, + { + name: "task check missing task id", + flags: map[string]string{ + "scenario": "task_check", + }, + wantErr: "--task-id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +task_result"} + cmd.Flags().String("scenario", "", "") + cmd.Flags().String("ticket", "", "") + cmd.Flags().String("task-id", "", "") + cmd.Flags().String("file-token", "", "") + for key, value := range tt.flags { + if err := cmd.Flags().Set(key, value); err != nil { + t.Fatalf("set --%s: %v", key, err) + } + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + err := DriveTaskResult.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +task_result"} + cmd.Flags().String("scenario", "", "") + cmd.Flags().String("ticket", "", "") + cmd.Flags().String("task-id", "", "") + cmd.Flags().String("file-token", "", "") + if err := cmd.Flags().Set("scenario", "export"); err != nil { + t.Fatalf("set --scenario: %v", err) + } + if err := cmd.Flags().Set("ticket", "tk_export"); err != nil { + t.Fatalf("set --ticket: %v", err) + } + if err := cmd.Flags().Set("file-token", "doc_123"); err != nil { + t.Fatalf("set --file-token: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := DriveTaskResult.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 1 { + t.Fatalf("expected 1 API call, got %d", len(got.API)) + } + if got.API[0].Params["token"] != "doc_123" { + t.Fatalf("export status params = %#v", got.API[0].Params) + } +} + +func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/tk_import", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "type": "sheet", + "job_status": 2, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveTaskResult, []string{ + "+task_result", + "--scenario", "import", + "--ticket", "tk_import", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) { + t.Fatalf("stdout missing ready=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) { + t.Fatalf("stdout missing job_status_label: %s", stdout.String()) + } +} + +func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/task_check", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"status": "pending"}, + }, + }) + + err := mountAndRunDrive(t, DriveTaskResult, []string{ + "+task_result", + "--scenario", "task_check", + "--task-id", "task_123", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"status": "pending"`)) { + t.Fatalf("stdout missing pending status: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) { + t.Fatalf("stdout missing ready=false: %s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) { + t.Fatalf("stdout missing failed=false: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index fb12d6c6..e8fbad3c 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -11,5 +11,10 @@ func Shortcuts() []common.Shortcut { DriveUpload, DriveDownload, DriveAddComment, + DriveExport, + DriveExportDownload, + DriveImport, + DriveMove, + DriveTaskResult, } } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go new file mode 100644 index 00000000..1fbfe019 --- /dev/null +++ b/shortcuts/drive/shortcuts_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import "testing" + +func TestShortcutsIncludesExpectedCommands(t *testing.T) { + t.Parallel() + + got := Shortcuts() + want := []string{ + "+upload", + "+download", + "+add-comment", + "+export", + "+export-download", + "+import", + "+move", + "+task_result", + } + + if len(got) != len(want) { + t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want)) + } + + seen := make(map[string]bool, len(got)) + for _, shortcut := range got { + if seen[shortcut.Command] { + t.Fatalf("duplicate shortcut command: %s", shortcut.Command) + } + seen[shortcut.Command] = true + } + + for _, command := range want { + if !seen[command] { + t.Fatalf("missing shortcut command %q in Shortcuts()", command) + } + } +} diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 147163bf..a8bcd58a 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -164,6 +164,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive | | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) | +| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling | +| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token | +| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | +| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive | +| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | ## API Resources @@ -177,6 +182,8 @@ lark-cli drive [flags] # 调用 API ### files - `copy` — 复制文件 + - `create_folder` — 新建文件夹 + - `list` — 获取文件夹下的清单 ### file.comments @@ -208,11 +215,21 @@ lark-cli drive [flags] # 调用 API - `subscription` — 订阅用户、应用维度事件(本次开放评论添加事件) - `subscription_status` — 查询用户、应用对指定事件的订阅状态 +### file.statistics + + - `get` — 获取文件统计信息 + +### file.view_records + + - `list` — 获取文档的访问者记录 + ## 权限表 | 方法 | 所需 scope | |------|-----------| | `files.copy` | `docs:document:copy` | +| `files.create_folder` | `space:folder:create` | +| `files.list` | `space:document:retrieve` | | `file.comments.batch_query` | `docs:document.comment:read` | | `file.comments.create_v2` | `docs:document.comment:create` | | `file.comments.list` | `docs:document.comment:read` | @@ -228,4 +245,5 @@ lark-cli drive [flags] # 调用 API | `user.remove_subscription` | `docs:event:subscribe` | | `user.subscription` | `docs:event:subscribe` | | `user.subscription_status` | `docs:event:subscribe` | - +| `file.statistics.get` | `drive:drive.metadata:readonly` | +| `file.view_records.list` | `drive:file:view_record:readonly` | diff --git a/skills/lark-drive/references/lark-drive-export-download.md b/skills/lark-drive/references/lark-drive-export-download.md new file mode 100644 index 00000000..42c4fdc8 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-export-download.md @@ -0,0 +1,50 @@ + +# drive +export-download + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +根据导出任务产物的 `file_token` 下载本地文件。通常与 `drive +task_result --scenario export` 配合使用。 + +## 命令 + +```bash +# 使用服务端返回的文件名下载到当前目录 +lark-cli drive +export-download \ + --file-token "" + +# 下载到指定目录 +lark-cli drive +export-download \ + --file-token "" \ + --output-dir ./exports + +# 指定本地文件名 +lark-cli drive +export-download \ + --file-token "" \ + --file-name "weekly-report.pdf" \ + --output-dir ./exports + +# 允许覆盖 +lark-cli drive +export-download \ + --file-token "" \ + --overwrite +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 导出完成后的产物 token | +| `--file-name` | 否 | 覆盖默认文件名 | +| `--output-dir` | 否 | 本地输出目录,默认当前目录 | +| `--overwrite` | 否 | 覆盖已存在文件 | + +## 使用顺序 + +1. 用 `drive +export` 发起导出 +2. 如果返回 `ticket` / `next_command`,用 `drive +task_result --scenario export --ticket --file-token ` 继续查 +3. 查到 `file_token` 后,用 `drive +export-download` 下载 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-export.md b/skills/lark-drive/references/lark-drive-export.md new file mode 100644 index 00000000..f60917c0 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-export.md @@ -0,0 +1,100 @@ + +# drive +export + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +把 `doc` / `docx` / `sheet` / `bitable` 导出到本地文件。这个 shortcut 内置有限轮询: + +- 如果导出任务在轮询窗口内完成,会直接下载到本地目录 +- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command` +- 后续继续查结果时,改用 `drive +task_result --scenario export` +- 拿到 `file_token` 后,改用 `drive +export-download` + +## 命令 + +```bash +# 导出新版文档为 pdf,默认保存到当前目录 +lark-cli drive +export \ + --token "" \ + --doc-type docx \ + --file-extension pdf + +# 导出旧版文档为 docx +lark-cli drive +export \ + --token "" \ + --doc-type doc \ + --file-extension docx + +# 导出 docx 为 markdown +# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content +lark-cli drive +export \ + --token "" \ + --doc-type docx \ + --file-extension markdown + +# 导出电子表格为 xlsx +lark-cli drive +export \ + --token "" \ + --doc-type sheet \ + --file-extension xlsx \ + --output-dir ./exports + +# 导出电子表格或多维表格为 csv 时,必须传 sub_id +lark-cli drive +export \ + --token "" \ + --doc-type "" \ + --file-extension csv \ + --sub-id "" \ + --output-dir ./exports + +# 允许覆盖已存在文件 +lark-cli drive +export \ + --token "" \ + --doc-type docx \ + --file-extension pdf \ + --overwrite +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | 源文档 token | +| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` | +| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` | +| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 | +| `--output-dir` | 否 | 本地输出目录,默认当前目录 | +| `--overwrite` | 否 | 覆盖已存在文件 | + +## 关键约束 + +- `markdown` 只支持 `docx` +- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id` +- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒 +- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询 + +## 推荐续跑方式 + +```bash +# 第一步:先尝试直接导出 +lark-cli drive +export \ + --token "" \ + --doc-type docx \ + --file-extension pdf + +# 如果返回 ready=false / timed_out=true,再继续查 +lark-cli drive +task_result \ + --scenario export \ + --ticket "" \ + --file-token "" + +# 查到 file_token 后下载 +lark-cli drive +export-download \ + --file-token "" \ + --output-dir ./exports +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md new file mode 100644 index 00000000..58041bda --- /dev/null +++ b/skills/lark-drive/references/lark-drive-import.md @@ -0,0 +1,80 @@ +# drive +import + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将本地文件(如 Word、TXT、Markdown、Excel 等)导入并转换为飞书在线云文档(docx、sheet、bitable)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。 + +## 命令 + +```bash +# 导入 Markdown 为新版文档 (docx) +lark-cli drive +import --file ./README.md --type docx + +# 导入 Excel 为电子表格 (sheet) +lark-cli drive +import --file ./data.xlsx --type sheet + +# 导入到指定文件夹,并指定导入后的文件名 +lark-cli drive +import --file ./data.csv --type bitable --folder-token --name "导入数据表" + +# 预览底层调用链(上传 -> 创建任务 -> 轮询) +lark-cli drive +import --file ./README.md --type docx --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`,最大支持 20MB | +| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) | +| `--folder-token` | 否 | 目标文件夹 token,不传则请求中的 `point.mount_key` 为空字符串,Import API 会将其解释为导入到云空间根目录 | +| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 | + +## 行为说明 + +- **三步执行**:此 shortcut 内部封装了完整流程: + 1. 自动调用素材上传接口 (`/open-apis/drive/v1/medias/upload_all`) 获取源文件的 `file_token` + 2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数 + 3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令 +- **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为“导入到调用者根目录”。 + +### 支持的文件类型转换 + +本地文件扩展名与目标云文档类型的对应关系如下: + +| 本地文件扩展名 | 可导入为 | 说明 | +|--------------|---------|------| +| `.docx`, `.doc` | `docx` | Microsoft Word 文档 | +| `.txt` | `docx` | 纯文本文件 | +| `.md`, `.markdown`, `.mark` | `docx` | Markdown 文档 | +| `.html` | `docx` | HTML 文档 | +| `.xlsx`, `.xls` | `sheet`, `bitable` | Microsoft Excel 表格 | +| `.csv` | `sheet`, `bitable` | CSV 数据文件 | + +> [!IMPORTANT] +> 文件扩展名与目标文档类型必须匹配,否则会返回验证错误: +> - 文档类文件(.docx, .doc, .txt, .md, .html)**只能**导入为 `docx` +> - 表格类文件(.xlsx, .xls, .csv)**只能**导入为 `sheet` 或 `bitable` +> - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet` + +- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。 +- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上: + - `ready=false` + - `timed_out=true` + - `next_command`:可直接复制执行的后续查询命令,例如 `lark-cli drive +task_result --scenario import --ticket ` +- 如果文件超过 20MB 上限,或者文件扩展名不被支持,执行时将抛出验证错误。 + +### 超时后的继续查询 + +当 `+import` 的内置轮询窗口结束但任务尚未完成时,使用返回结果中的 `ticket` 继续查询: + +```bash +lark-cli drive +task_result --scenario import --ticket +``` + +> [!CAUTION] +> `drive +import` 是**写入操作** —— 执行前必须确认用户意图。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-move.md b/skills/lark-drive/references/lark-drive-move.md new file mode 100644 index 00000000..57d93132 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-move.md @@ -0,0 +1,92 @@ + +# drive +move + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将文件或文件夹移动到用户云空间的其他位置。 + +## 命令 + +```bash +# 移动文件到指定文件夹 +lark-cli drive +move \ + --file-token \ + --type file \ + --folder-token + +# 移动文档到指定文件夹 +lark-cli drive +move \ + --file-token \ + --type docx \ + --folder-token + +# 移动文件夹(异步操作,会自动有限轮询任务状态) +lark-cli drive +move \ + --file-token \ + --type folder \ + --folder-token + +# 移动到根文件夹(不指定 --folder-token) +lark-cli drive +move \ + --file-token \ + --type file +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 需要移动的文件或文件夹 token | +| `--type` | 是 | 文件类型,可选值:`file` (普通文件)、`docx` (新版文档)、`bitable` (多维表格)、`doc` (旧版文档)、`sheet` (电子表格)、`mindnote` (思维笔记)、`folder` (文件夹)、`slides` (幻灯片) | +| `--folder-token` | 否 | 目标文件夹 token,不指定则移动到根文件夹 | + +## 文件类型说明 + +| 类型 | 说明 | +|------|------| +| `file` | 普通文件 | +| `docx` | 新版云文档 | +| `doc` | 旧版云文档 | +| `sheet` | 电子表格 | +| `bitable` | 多维表格 | +| `mindnote` | 思维笔记 | +| `slides` | 幻灯片 | +| `folder` | 文件夹(移动文件夹是异步操作) | + +## 行为说明 + +- **普通文件移动**:同步操作,立即完成 +- **文件夹移动**:异步操作,接口返回 `task_id`,shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果 +- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command` +- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id ` 继续查询 +- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间") +- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限 + +## 推荐续跑方式 + +```bash +# 第一步:先直接移动文件夹 +lark-cli drive +move \ + --file-token \ + --type folder \ + --folder-token + +# 如果返回 ready=false / timed_out=true,再继续查 +lark-cli drive +task_result \ + --scenario task_check \ + --task-id +``` + +## 限制 + +- 被移动的文件不支持 wiki 文档 +- 该接口不支持并发调用 +- 调用频率上限为 5 QPS 且 10000 次/天 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-task-result.md b/skills/lark-drive/references/lark-drive-task-result.md new file mode 100644 index 00000000..4c42aaed --- /dev/null +++ b/skills/lark-drive/references/lark-drive-task-result.md @@ -0,0 +1,170 @@ + +# drive +task_result + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。 + +## 命令 + +```bash +# 查询导入任务结果 +lark-cli drive +task_result \ + --scenario import \ + --ticket + +# 查询导出任务结果 +lark-cli drive +task_result \ + --scenario export \ + --ticket \ + --file-token + +# 查询移动/删除文件夹任务状态 +lark-cli drive +task_result \ + --scenario task_check \ + --task-id +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) | +| `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** | +| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** | +| `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** | + +## 场景说明 + +| 场景 | 说明 | 所需参数 | +|------|------|----------| +| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` | +| `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` | +| `task_check` | 文件夹移动/删除任务 | `--task-id` | + +## 返回结果 + +### Import 场景返回 + +```json +{ + "scenario": "import", + "ticket": "", + "type": "sheet", + "ready": true, + "failed": false, + "job_status": 0, + "job_status_label": "success", + "job_error_msg": "success", + "token": "", + "url": "https://example.feishu.cn/sheets/", + "extra": ["2000"] +} +``` + +**字段说明:** +- `ready`: 是否已经导入完成,可直接使用 `token` / `url` +- `failed`: 是否已经失败 +- `job_status`: 服务端返回的原始状态码 +- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing` +- `token`: 导入后的文档 token +- `url`: 导入后的文档链接 + +### Export 场景返回 + +```json +{ + "scenario": "export", + "ticket": "", + "ready": true, + "failed": false, + "file_extension": "pdf", + "type": "doc", + "file_name": "docName", + "file_token": "", + "file_size": 34356, + "job_error_msg": "success", + "job_status": 0, + "job_status_label": "success" +} +``` + +**字段说明:** +- `ready`: 是否已经完成导出,可直接使用 `file_token` +- `failed`: 是否已经失败 +- `job_status`: 服务端返回的原始状态码 +- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing` +- `file_token`: 导出文件的 token,用于下载 +- `file_extension`: 导出文件扩展名 +- `file_size`: 导出文件大小(字节) + +### Task_check 场景返回 + +```json +{ + "scenario": "task_check", + "task_id": "", + "status": "success", + "ready": true, + "failed": false +} +``` + +**字段说明:** +- `status`: 任务状态,`success`=成功,`failed`=失败,`pending`=处理中 +- `ready`: 是否已经完成 +- `failed`: 是否已经失败 + +## 使用场景 + +### 配合 +import 使用 + +```bash +# 1. 创建导入任务 +lark-cli drive +import --file ./data.xlsx --type sheet +# 若任务很快完成:直接返回 token / url +# 若内置轮询超时:返回 ready=false、ticket 和 next_command + +# 2. 轮询导入结果 +lark-cli drive +task_result --scenario import --ticket +``` + +### 配合 +move 使用 + +```bash +# 1. 移动文件夹(异步操作) +lark-cli drive +move --file-token --type folder --folder-token +# 若轮询窗口内完成:直接返回 ready=true +# 若内置轮询结束仍未完成:返回 ready=false、task_id 和 next_command + +# 2. 轮询移动结果 +lark-cli drive +task_result --scenario task_check --task-id +``` + +### 配合 +export 使用 + +```bash +# 1. 发起导出 +lark-cli drive +export --token --doc-type docx --file-extension pdf +# 若轮询窗口内完成:直接下载本地文件 +# 若内置轮询结束仍未完成:返回 ready=false、ticket 和 next_command + +# 2. 继续查询导出结果 +lark-cli drive +task_result --scenario export --ticket --file-token + +# 3. 拿到 file_token 后下载 +lark-cli drive +export-download --file-token +``` + +## 权限要求 + +| 场景 | 所需 scope | +|------|-----------| +| import | `drive:drive.metadata:readonly` | +| export | `drive:drive.metadata:readonly` | +| task_check | `drive:drive.metadata:readonly` | + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 875a1ddc..918f9c41 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -23,11 +23,23 @@ lark-cli wiki [flags] # 调用 API ### spaces - - `get_node` — 获取知识空间节点信息 +- `get` — 获取知识空间信息 +- `get_node` — 获取知识空间节点信息 +- `list` — 获取知识空间列表 + +### nodes + +- `copy` — 创建知识空间节点副本 +- `create` — 创建知识空间节点 +- `list` — 获取知识空间子节点列表 ## 权限表 | 方法 | 所需 scope | |------|-----------| +| `spaces.get` | `wiki:space:read` | | `spaces.get_node` | `wiki:node:read` | - +| `spaces.list` | `wiki:space:retrieve` | +| `nodes.copy` | `wiki:node:copy` | +| `nodes.create` | `wiki:node:create` | +| `nodes.list` | `wiki:node:retrieve` |