mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
403 lines
13 KiB
Go
403 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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
|
|
)
|
|
|
|
const (
|
|
// These limits follow the current product-side import constraints per format.
|
|
driveImport20MBFileSizeLimit int64 = 20 * 1024 * 1024
|
|
driveImport100MBFileSizeLimit int64 = 100 * 1024 * 1024
|
|
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
|
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
|
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
|
)
|
|
|
|
// 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"},
|
|
"csv": {"sheet", "bitable"},
|
|
"base": {"bitable"},
|
|
"pptx": {"slides"},
|
|
}
|
|
|
|
// driveImportSpec contains the user-facing import inputs after normalization.
|
|
type driveImportSpec struct {
|
|
FilePath string
|
|
DocType string
|
|
FolderToken string
|
|
Name string
|
|
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
|
}
|
|
|
|
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{} {
|
|
body := 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,
|
|
},
|
|
}
|
|
|
|
if s.DocType == "bitable" && s.TargetToken != "" {
|
|
body["token"] = s.TargetToken
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
// 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 := runtime.FileIO().Stat(filePath)
|
|
if err != nil {
|
|
return "", common.WrapInputStatError(err)
|
|
}
|
|
|
|
fileSize := importInfo.Size()
|
|
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
extra, err := buildImportMediaExtra(filePath, docType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
|
// upload_all for import works without parent_node; omitting it preserves
|
|
// the existing root-level import staging behavior.
|
|
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
|
FilePath: filePath,
|
|
FileName: fileName,
|
|
FileSize: fileSize,
|
|
ParentType: "ccm_import_open",
|
|
Extra: extra,
|
|
})
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
|
// upload_prepare is stricter than upload_all here and expects parent_node to
|
|
// be sent explicitly, even when import uses the implicit root staging area.
|
|
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
|
FilePath: filePath,
|
|
FileName: fileName,
|
|
FileSize: fileSize,
|
|
ParentType: "ccm_import_open",
|
|
ParentNode: "",
|
|
Extra: extra,
|
|
})
|
|
}
|
|
|
|
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
|
// The import media endpoint uses extra to decide both the target native type
|
|
// and how to interpret the uploaded source file.
|
|
extraBytes, err := json.Marshal(map[string]string{
|
|
"obj_type": docType,
|
|
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
|
})
|
|
if err != nil {
|
|
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
|
|
}
|
|
return string(extraBytes), nil
|
|
}
|
|
|
|
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
|
// Keep the limit mapping local to import flows so we do not widen behavior
|
|
// changes beyond drive +import.
|
|
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
|
case "docx", "doc":
|
|
return driveImport600MBFileSizeLimit, true
|
|
case "pptx":
|
|
return driveImport500MBFileSizeLimit, true
|
|
case "txt", "md", "mark", "markdown", "html", "xls", "base":
|
|
return driveImport20MBFileSizeLimit, true
|
|
case "xlsx":
|
|
return driveImport800MBFileSizeLimit, true
|
|
case "csv":
|
|
if docType == "bitable" {
|
|
return driveImport100MBFileSizeLimit, true
|
|
}
|
|
return driveImport20MBFileSizeLimit, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
|
|
limit, ok := driveImportFileSizeLimit(filePath, docType)
|
|
if !ok || fileSize <= limit {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
|
if ext == "csv" {
|
|
// CSV is the only source format whose limit depends on the target type.
|
|
return output.ErrValidation(
|
|
"file %s exceeds %s import limit for .csv when importing as %s",
|
|
common.FormatSize(fileSize),
|
|
common.FormatSize(limit),
|
|
docType,
|
|
)
|
|
}
|
|
|
|
return output.ErrValidation(
|
|
"file %s exceeds %s import limit for .%s",
|
|
common.FormatSize(fileSize),
|
|
common.FormatSize(limit),
|
|
ext,
|
|
)
|
|
}
|
|
|
|
// 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, .pptx)")
|
|
}
|
|
|
|
switch spec.DocType {
|
|
case "docx", "sheet", "bitable", "slides":
|
|
default:
|
|
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", 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, base, pptx", 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", "csv":
|
|
hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType)
|
|
case "xls":
|
|
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
|
case "base":
|
|
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
|
|
case "pptx":
|
|
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", 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)
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(spec.TargetToken) != "" {
|
|
if spec.DocType != "bitable" {
|
|
return output.ErrValidation("--target-token is only supported when --type is bitable")
|
|
}
|
|
if err := validate.ResourceName(spec.TargetToken, "--target-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
|
|
}
|