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

264 lines
9.8 KiB
Go

// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"reflect"
"strings"
"time"
"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/shortcut"
)
const viewPresetReadbackAttempts = 8
var viewPresetSleep = time.Sleep
var ViewPresetApply = shortcut.Shortcut{
Service: "aitable",
Command: "+view-preset-apply",
Product: serverMain,
Description: "按视图精确名称幂等创建或更新预设,并读回校验类型和 config",
Intent: "当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: aitableCompositeContract(
"+view-preset-apply",
"按视图精确名称幂等创建或更新预设,并读回校验类型和 config",
"当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。",
"只做一次性新建可用 view create;同名视图不唯一或现有视图类型不同必须人工处理",
`dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "预设视图精确名称", Required: true},
{Name: "view-type", Type: shortcut.FlagString, Desc: "视图类型", Required: true, Enum: []string{"Grid", "Kanban", "Gantt", "Calendar", "Gallery"}},
{Name: "config", Type: shortcut.FlagString, Desc: "目标 config JSON 对象", Required: true},
},
Tips: []string{`dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeViewPresetApply(rt)
},
}
func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
config, err := parseJSONObject("config", rt.Str("config"))
if err != nil {
return err
}
if len(config) == 0 {
return apperrors.NewValidation("--config 必须是非空 JSON 对象")
}
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
name, viewType := strings.TrimSpace(rt.Str("name")), rt.Str("view-type")
preflight, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
views, found := findNamedObjectList(preflight, "views", "viewList")
if !found {
return fmt.Errorf("get_views preflight is missing the views collection")
}
matches := viewsByExactName(views, name)
if len(matches) > 1 {
return apperrors.NewValidation(fmt.Sprintf("精确名称 %q 匹配到 %d 个视图,拒绝选择", name, len(matches)), apperrors.WithReason("target_ambiguous"), apperrors.WithExecutionStarted(false))
}
action, tool := "create", "create_view"
params := map[string]any{"baseId": baseID, "tableId": tableID, "viewName": name, "viewType": viewType, "config": config}
viewID := ""
if len(matches) == 1 {
action, tool = "update", "update_view"
viewID = stringValue(matches[0], "viewId", "id")
if viewID == "" {
return fmt.Errorf("matched view is missing viewId")
}
actualType := stringValue(matches[0], "viewType", "type")
if actualType != "" && actualType != viewType {
return apperrors.NewValidation(fmt.Sprintf("同名视图类型为 %s,不能原地改为 %s", actualType, viewType), apperrors.WithReason("target_type_conflict"), apperrors.WithExecutionStarted(false))
}
params = map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "newViewName": name, "config": config}
if presetViewMatches(matches[0], viewType, config) {
result := newCompositeResult("view_preset_apply")
result.Status = "unchanged"
result.Executed = false
result.Resolved = map[string]any{"action": "unchanged", "viewId": viewID, "name": name}
result.Verification = map[string]any{"status": "verified", "viewId": viewID}
return rt.Output(result)
}
}
result := newCompositeResult("view_preset_apply")
result.Resolved = map[string]any{"action": action, "name": name, "viewId": viewID}
result.Plan = []compositeStep{{Index: 1, Name: action + " view preset", Tool: tool, Status: "planned", Arguments: params}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
var verifiedMatches []map[string]any
var verifyErr error
for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 12*time.Second {
backoff = 12 * time.Second
}
viewPresetSleep(backoff)
}
readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifyErr = readErr
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches = viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
if len(verifiedMatches) > 1 {
break
}
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
break
}
}
}
if verifyErr != nil {
result.Status = "unknown"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "viewId": viewID, "name": name})
if writeErr != nil {
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
}
return compositeError(result, verifyErr, action == "update")
}
result.Resolved["viewId"] = viewID
result.CompletedCount = 1
result.Verification = map[string]any{"status": "verified", "viewId": viewID, "viewType": viewType}
result.Result = map[string]any{"action": action, "viewId": viewID, "view": verifiedMatches[0]}
if writeErr != nil {
result.Status = "recovered"
result.Warnings = append(result.Warnings, "write response was an error, but the exact view preset was proven by read-back")
}
return rt.Output(result)
}
func viewsByExactName(views []map[string]any, name string) []map[string]any {
out := make([]map[string]any, 0)
for _, view := range views {
if stringValue(view, "viewName", "name", "title") == name {
out = append(out, view)
}
}
return out
}
func presetViewMatches(view map[string]any, viewType string, config map[string]any) bool {
actualType := stringValue(view, "viewType", "type")
if actualType != "" && actualType != viewType {
return false
}
actualConfig := make(map[string]any, len(config))
if nested, ok := view["config"].(map[string]any); ok {
for key, value := range nested {
actualConfig[key] = value
}
}
for key := range config {
if value, ok := view[key]; ok {
actualConfig[key] = value
}
}
if _, wanted := config["visibleFieldIds"]; wanted {
if visible, ok := projectedVisibleFieldIDs(view); ok {
actualConfig["visibleFieldIds"] = visible
}
}
return mapContains(actualConfig, config)
}
// get_views projects visible fields as columns plus hiddenFields instead of
// echoing create_view's config.visibleFieldIds input. Different deployments
// return hiddenFields either as a fieldId-keyed object or a parallel array.
func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
columns, columnsOK := view["columns"].([]any)
custom, customOK := view["custom"].(map[string]any)
if !columnsOK || !customOK {
return nil, false
}
if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
visible := make([]any, 0, len(columns))
for _, column := range columns {
fieldID, fieldOK := column.(string)
if !fieldOK {
return nil, false
}
isHidden, exists := hidden[fieldID]
if !exists {
return nil, false
}
hiddenFlag, hiddenTypeOK := isHidden.(bool)
if !hiddenTypeOK {
return nil, false
}
if !hiddenFlag {
visible = append(visible, fieldID)
}
}
return visible, true
}
hidden, hiddenOK := custom["hiddenFields"].([]any)
if !hiddenOK || len(columns) != len(hidden) {
return nil, false
}
visible := make([]any, 0, len(columns))
for index, column := range columns {
fieldID, fieldOK := column.(string)
isHidden, hiddenTypeOK := hidden[index].(bool)
if !fieldOK || !hiddenTypeOK {
return nil, false
}
if !isHidden {
visible = append(visible, fieldID)
}
}
return visible, true
}
func mapContains(actual, expected map[string]any) bool {
for key, want := range expected {
got, exists := actual[key]
if !exists {
return false
}
wantMap, wantIsMap := want.(map[string]any)
gotMap, gotIsMap := got.(map[string]any)
if wantIsMap {
if !gotIsMap || !mapContains(gotMap, wantMap) {
return false
}
continue
}
if !reflect.DeepEqual(got, want) {
return false
}
}
return true
}