mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add drive import, export, move, and task result shortcuts (#194)
Change-Id: I0938dcf587e377afc4ab7133f1e8ff1e2412e566
This commit is contained in:
245
shortcuts/drive/drive_export.go
Normal file
245
shortcuts/drive/drive_export.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
371
shortcuts/drive/drive_export_common.go
Normal file
371
shortcuts/drive/drive_export_common.go
Normal file
@@ -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 ""
|
||||
}
|
||||
}
|
||||
67
shortcuts/drive/drive_export_common_test.go
Normal file
67
shortcuts/drive/drive_export_common_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
60
shortcuts/drive/drive_export_download.go
Normal file
60
shortcuts/drive/drive_export_download.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
516
shortcuts/drive/drive_export_test.go
Normal file
516
shortcuts/drive/drive_export_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
246
shortcuts/drive/drive_import.go
Normal file
246
shortcuts/drive/drive_import.go
Normal file
@@ -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": "<file_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("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
263
shortcuts/drive/drive_import_common.go
Normal file
263
shortcuts/drive/drive_import_common.go
Normal file
@@ -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
|
||||
}
|
||||
131
shortcuts/drive/drive_import_common_test.go
Normal file
131
shortcuts/drive/drive_import_common_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
155
shortcuts/drive/drive_import_test.go
Normal file
155
shortcuts/drive/drive_import_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
153
shortcuts/drive/drive_move.go
Normal file
153
shortcuts/drive/drive_move.go
Normal file
@@ -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("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
160
shortcuts/drive/drive_move_common.go
Normal file
160
shortcuts/drive/drive_move_common.go
Normal file
@@ -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
|
||||
}
|
||||
194
shortcuts/drive/drive_move_common_test.go
Normal file
194
shortcuts/drive/drive_move_common_test.go
Normal file
@@ -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"] != "<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())
|
||||
}
|
||||
}
|
||||
77
shortcuts/drive/drive_move_test.go
Normal file
77
shortcuts/drive/drive_move_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
190
shortcuts/drive/drive_task_result.go
Normal file
190
shortcuts/drive/drive_task_result.go
Normal file
@@ -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
|
||||
}
|
||||
192
shortcuts/drive/drive_task_result_test.go
Normal file
192
shortcuts/drive/drive_task_result_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,10 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveUpload,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
40
shortcuts/drive/shortcuts_test.go
Normal file
40
shortcuts/drive/shortcuts_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [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 <resource> <method> [flags] # 调用 API
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -208,11 +215,21 @@ lark-cli drive <resource> <method> [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 <resource> <method> [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` |
|
||||
|
||||
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
@@ -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 "<EXPORTED_FILE_TOKEN>"
|
||||
|
||||
# 下载到指定目录
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 指定本地文件名
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--file-name "weekly-report.pdf" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 导出完成后的产物 token |
|
||||
| `--file-name` | 否 | 覆盖默认文件名 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
## 使用顺序
|
||||
|
||||
1. 用 `drive +export` 发起导出
|
||||
2. 如果返回 `ticket` / `next_command`,用 `drive +task_result --scenario export --ticket <ticket> --file-token <source_token>` 继续查
|
||||
3. 查到 `file_token` 后,用 `drive +export-download` 下载
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
100
skills/lark-drive/references/lark-drive-export.md
Normal file
100
skills/lark-drive/references/lark-drive-export.md
Normal file
@@ -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 "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出旧版文档为 docx
|
||||
lark-cli drive +export \
|
||||
--token "<DOC_TOKEN>" \
|
||||
--doc-type doc \
|
||||
--file-extension docx
|
||||
|
||||
# 导出 docx 为 markdown
|
||||
# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension markdown
|
||||
|
||||
# 导出电子表格为 xlsx
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_TOKEN>" \
|
||||
--doc-type sheet \
|
||||
--file-extension xlsx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出电子表格或多维表格为 csv 时,必须传 sub_id
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_OR_BITABLE_TOKEN>" \
|
||||
--doc-type "<sheet|bitable>" \
|
||||
--file-extension csv \
|
||||
--sub-id "<SUB_ID>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖已存在文件
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_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 "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket "<TICKET>" \
|
||||
--file-token "<DOCX_TOKEN>"
|
||||
|
||||
# 查到 file_token 后下载
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
80
skills/lark-drive/references/lark-drive-import.md
Normal file
80
skills/lark-drive/references/lark-drive-import.md
Normal file
@@ -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 <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 <TICKET>`
|
||||
- 如果文件超过 20MB 上限,或者文件扩展名不被支持,执行时将抛出验证错误。
|
||||
|
||||
### 超时后的继续查询
|
||||
|
||||
当 `+import` 的内置轮询窗口结束但任务尚未完成时,使用返回结果中的 `ticket` 继续查询:
|
||||
|
||||
```bash
|
||||
lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> `drive +import` 是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
92
skills/lark-drive/references/lark-drive-move.md
Normal file
92
skills/lark-drive/references/lark-drive-move.md
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
# drive +move
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将文件或文件夹移动到用户云空间的其他位置。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 移动文件到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文档到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文件夹(异步操作,会自动有限轮询任务状态)
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动到根文件夹(不指定 --folder-token)
|
||||
lark-cli drive +move \
|
||||
--file-token <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 <TASK_ID>` 继续查询
|
||||
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间")
|
||||
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先直接移动文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 被移动的文件不支持 wiki 文档
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS 且 10000 次/天
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
@@ -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 <IMPORT_TICKET>
|
||||
|
||||
# 查询导出任务结果
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket <EXPORT_TICKET> \
|
||||
--file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 查询移动/删除文件夹任务状态
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <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": "<IMPORT_TICKET>",
|
||||
"type": "sheet",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"job_status": 0,
|
||||
"job_status_label": "success",
|
||||
"job_error_msg": "success",
|
||||
"token": "<IMPORTED_DOC_TOKEN>",
|
||||
"url": "https://example.feishu.cn/sheets/<IMPORTED_DOC_TOKEN>",
|
||||
"extra": ["2000"]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 是否已经导入完成,可直接使用 `token` / `url`
|
||||
- `failed`: 是否已经失败
|
||||
- `job_status`: 服务端返回的原始状态码
|
||||
- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing`
|
||||
- `token`: 导入后的文档 token
|
||||
- `url`: 导入后的文档链接
|
||||
|
||||
### Export 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "export",
|
||||
"ticket": "<EXPORT_TICKET>",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"file_extension": "pdf",
|
||||
"type": "doc",
|
||||
"file_name": "docName",
|
||||
"file_token": "<EXPORTED_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": "<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 <IMPORT_TICKET>
|
||||
```
|
||||
|
||||
### 配合 +move 使用
|
||||
|
||||
```bash
|
||||
# 1. 移动文件夹(异步操作)
|
||||
lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <TARGET_FOLDER_TOKEN>
|
||||
# 若轮询窗口内完成:直接返回 ready=true
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、task_id 和 next_command
|
||||
|
||||
# 2. 轮询移动结果
|
||||
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
|
||||
```
|
||||
|
||||
### 配合 +export 使用
|
||||
|
||||
```bash
|
||||
# 1. 发起导出
|
||||
lark-cli drive +export --token <SOURCE_DOC_TOKEN> --doc-type docx --file-extension pdf
|
||||
# 若轮询窗口内完成:直接下载本地文件
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、ticket 和 next_command
|
||||
|
||||
# 2. 继续查询导出结果
|
||||
lark-cli drive +task_result --scenario export --ticket <EXPORT_TICKET> --file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 3. 拿到 file_token 后下载
|
||||
lark-cli drive +export-download --file-token <EXPORTED_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) -- 认证和全局参数
|
||||
@@ -23,11 +23,23 @@ lark-cli wiki <resource> <method> [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` |
|
||||
|
||||
Reference in New Issue
Block a user