Files
dingtalk-workspace-cli/internal/shortcut/aitable/table_bootstrap.go

177 lines
7.8 KiB
Go

// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var TableBootstrap = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "aitable",
Command: "+table-bootstrap",
Product: serverMain,
Description: "在已有 Base 中一次创建数据表和字段,自动分片并读回验证",
Intent: "当你已有 baseId、需要新增一张带完整字段结构的数据表时使用;替代 table create 后连续 field create 和手工验证。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContractWithResult(
"+table-bootstrap",
"在已有 Base 中一次创建数据表和字段,自动分片并读回验证",
"当你已有 baseId、需要新增一张带完整字段结构的数据表时使用;替代 table create 后连续 field create 和手工验证。",
"需要同时新建 Base 用 +base-bootstrap;复制现有表用 +table-copy;只补字段用 field create",
`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`,
aitableTableBootstrapResultSpec(),
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新数据表名称", Required: true},
{Name: "fields", Type: shortcut.FlagString, Desc: "字段结构 JSON 数组;字段对象使用 fieldName/type/config", Required: true},
},
Tips: []string{
`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeTableBootstrap(rt)
},
}
type createdTableStructure struct {
TableID string
Fields []map[string]any
Warnings []string
}
func parseBootstrapFields(raw string) ([]any, error) {
value, err := parseJSONAny("fields", raw)
if err != nil {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields 不是合法 JSON 数组:%v", err))
}
fields, ok := value.([]any)
if !ok {
return nil, tableBootstrapValidation("--fields 必须是 JSON 数组")
}
if len(fields) > 100 {
return nil, tableBootstrapValidation("--fields 最多接受 100 个字段")
}
seen := map[string]bool{}
for index, rawField := range fields {
field, ok := rawField.(map[string]any)
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
if !ok || name == "" || strings.TrimSpace(stringValue(field, "type")) == "" {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d] 必须包含 fieldName 和 type", index))
}
if seen[name] {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d].fieldName %q 不能重复", index, name))
}
seen[name] = true
if config, exists := field["config"]; exists {
if _, ok := config.(map[string]any); !ok {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d].config 必须是 JSON 对象", index))
}
}
}
return fields, nil
}
func tableBootstrapValidation(message string) error {
return apperrors.NewValidation(message,
apperrors.WithHint("字段对象使用 fieldName/type/config;已知参数时直接执行,不需要先调用 --help"),
apperrors.WithActions(`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`),
apperrors.WithAvailableFlags("base-id", "name", "fields"),
)
}
func executeTableBootstrap(rt *shortcut.RuntimeContext) error {
fields, err := parseBootstrapFields(rt.Str("fields"))
if err != nil {
return err
}
baseID, tableName := rt.Str("base-id"), rt.Str("name")
result := newCompositeResult("table_bootstrap")
result.RequestedCount = len(fields)
result.Resolved = map[string]any{"baseId": baseID, "tableName": tableName}
result.Plan = []compositeStep{{Index: 1, Name: "create and verify table", Tool: "create_table", Status: "planned", Count: len(fields)}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
created, err := createAndVerifyTableStructure(rt, baseID, tableName, fields)
result.Warnings = append(result.Warnings, created.Warnings...)
if created.TableID != "" {
result.Resolved["tableId"] = created.TableID
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": created.TableID, "name": tableName})
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+table-get", "--base-id", baseID, "--table-id", created.TableID, "--format", "json")
}
if err != nil {
if created.TableID == "" {
result.Status = "unknown"
result.Checkpoint = map[string]any{"step": "resolve table by exact name before retrying", "baseId": baseID, "tableName": tableName}
} else {
result.Status = "partial_success"
result.Checkpoint = map[string]any{"step": "verify or repair table fields", "baseId": baseID, "tableId": created.TableID}
}
return compositeError(result, err, false)
}
result.CompletedCount = len(fields)
result.CompletedSteps = []compositeStep{{Index: 1, Name: "create and verify table", Tool: "create_table", Status: "completed", Count: len(fields), Result: map[string]any{"tableId": created.TableID, "fieldCount": len(created.Fields)}}}
result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableId": created.TableID, "fieldCount": len(created.Fields)}
result.Result = map[string]any{"baseId": baseID, "tableId": created.TableID, "tableName": tableName, "fields": created.Fields}
result.NextCommand = ""
return rt.Output(result)
}
func createAndVerifyTableStructure(rt *shortcut.RuntimeContext, baseID, tableName string, fields []any) (createdTableStructure, error) {
created := createdTableStructure{}
initialEnd := minInt(15, len(fields))
createData, err := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": baseID, "tableName": tableName, "fields": fields[:initialEnd],
})
created.TableID = findStringByKeys(createData, "tableId", "sheetId")
if err != nil || created.TableID == "" {
if err == nil {
err = fmt.Errorf("create_table response is missing tableId")
}
return created, err
}
for offset := initialEnd; offset < len(fields); offset += 15 {
end := minInt(offset+15, len(fields))
if _, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
"baseId": baseID, "tableId": created.TableID, "fields": fields[offset:end],
}); fieldErr != nil {
created.Warnings = append(created.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final read-back decides success: %v", offset, fieldErr))
}
}
detail, err := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{created.TableID}})
if err != nil || !deepContainsString(detail, created.TableID) {
if err == nil {
err = fmt.Errorf("get_tables does not identify created tableId %s", created.TableID)
}
return created, err
}
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": created.TableID})
created.Fields, _ = findNamedObjectList(fieldsData, "fields", "fieldList")
if err == nil && created.Fields == nil {
err = fmt.Errorf("field read-back for table %s is missing the fields collection", created.TableID)
}
if err == nil {
err = verifyDeclaredFieldStructures(created.Fields, fields)
}
if err != nil {
return created, err
}
return created, nil
}