Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 75 additions & 92 deletions shortcuts/drive/drive_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,11 @@ 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"
Expand All @@ -33,7 +28,7 @@ var DriveImport = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
Expand All @@ -53,19 +48,15 @@ var DriveImport = common.Shortcut{
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
fileSize, err := preflightDriveImportFile(&spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}

dry := common.NewDryRunAPI()
dry.Desc("3-step orchestration: upload file -> create import task -> poll status")
dry.Desc("Upload file (single-part or multipart) -> 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,
})
appendDriveImportUploadDryRun(dry, spec, fileSize)

dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Expand All @@ -84,13 +75,9 @@ var DriveImport = common.Shortcut{
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)
if _, err := preflightDriveImportFile(&spec); err != nil {
return err
}
spec.FilePath = safeFilePath

// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
Expand Down Expand Up @@ -151,6 +138,72 @@ var DriveImport = common.Shortcut{
},
}

func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
if err != nil {
return 0, output.ErrValidation("unsafe file path: %s", err)
}
spec.FilePath = safeFilePath

info, err := os.Stat(spec.FilePath)
if err != nil {
return 0, output.ErrValidation("cannot read file: %s", err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
}
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
return 0, err
}
return info.Size(), nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
if err != nil {
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
}

if fileSize > maxDriveUploadFileSize {
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc("[1a] Initialize multipart upload").
Body(map[string]interface{}{
"file_name": spec.SourceFileName(),
"parent_type": "ccm_import_open",
"parent_node": "",
"size": "<file_size>",
"extra": extra,
})
Comment thread
liujinkun2025 marked this conversation as resolved.
dry.POST("/open-apis/drive/v1/medias/upload_part").
Desc("[1b] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
})
dry.POST("/open-apis/drive/v1/medias/upload_finish").
Desc("[1c] Finalize multipart upload and get file_token").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return
}

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": extra,
"file": "@" + spec.FilePath,
})
}

// importTargetFileName returns the explicit import name when present, otherwise
// derives one from the local file name.
func importTargetFileName(filePath, explicitName string) string {
Expand All @@ -174,73 +227,3 @@ func importDefaultFileName(filePath string) string {
}
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
}
Loading
Loading