137 lines
5.7 KiB
Go
137 lines
5.7 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package aitable
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
)
|
|
|
|
func executeBaseCopy(rt *shortcut.RuntimeContext) error {
|
|
baseID := strings.TrimSpace(rt.Str("base-id"))
|
|
targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
|
|
if !validCompositeOpaqueID(baseID) {
|
|
return apperrors.NewValidation("--base-id 不是合法的 Base ID")
|
|
}
|
|
if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
|
|
return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
|
|
}
|
|
params := map[string]any{
|
|
"baseId": baseID,
|
|
"targetFolderId": targetFolderID,
|
|
"onlyCopyMeta": rt.Bool("only-struct"),
|
|
}
|
|
result := newCompositeResult("base_copy")
|
|
result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
|
|
result.Plan = []compositeStep{
|
|
{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
|
|
{Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
|
|
}
|
|
if rt.DryRun() {
|
|
result.Status = "planned"
|
|
result.Executed = false
|
|
return rt.Output(result)
|
|
}
|
|
|
|
targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
|
|
if actualTargetID == "" || actualTargetID != targetFolderID {
|
|
return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
|
|
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
|
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
|
}
|
|
targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
|
|
if targetType == "" {
|
|
return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
|
|
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
|
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
|
}
|
|
if !strings.EqualFold(targetType, "folder") {
|
|
return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
|
|
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
|
|
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
|
}
|
|
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
|
|
|
|
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
|
|
newBaseID := findStringByKeys(writeData, "newBaseId")
|
|
if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
|
|
return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
|
|
apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
|
|
apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
|
|
apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
|
|
apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
|
|
}
|
|
if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
|
|
cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
|
|
if writeErr != nil {
|
|
cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
|
|
}
|
|
result.Status = "unknown"
|
|
return compositeError(result, cause, false)
|
|
}
|
|
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
|
|
|
|
readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
|
|
if verifyErr == nil {
|
|
actualID := findStringByKeys(readBack, "baseId")
|
|
if actualID == "" {
|
|
verifyErr = fmt.Errorf("get_base read-back is missing baseId")
|
|
} else if actualID != newBaseID {
|
|
verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
|
|
}
|
|
}
|
|
if verifyErr != nil {
|
|
result.Status = "partial_success"
|
|
result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
|
|
return compositeError(result, verifyErr, false)
|
|
}
|
|
|
|
result.CompletedCount = 2
|
|
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
|
|
result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
|
|
result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
|
|
return rt.Output(result)
|
|
}
|
|
|
|
func copyBaseRejectedTarget(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
return strings.Contains(message, "invalid target folder") ||
|
|
strings.Contains(message, "invalid targetfolder")
|
|
}
|
|
|
|
func validCompositeOpaqueID(value string) bool {
|
|
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func pureNumericID(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|