Files
dingtalk-workspace-cli/internal/helpers/sheet_cell_validation.go
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00

515 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// sheet range update 单元格结构的客户端校验,与 wukong 产品层 validateComplexValueCell
// 系列保持一致:拦住缺 type / richText 与整格 hyperlink 共存 / hyperlink.text 与
// cell.text 不一致 / 非法 dataValidation 等非法输入,避免把脏 payload 发到服务端。
var sheetComplexValueStyleFields = map[string]string{
"bold": "bool",
"italic": "bool",
"underline": "bool",
"strike": "bool",
"color": "string",
"size": "number",
}
func sheetValidateComplexValueStyle(style map[string]any, path string) error {
for k, v := range style {
kind, ok := sheetComplexValueStyleFields[k]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 含未知字段 %q(合法字段: bold/italic/underline/strike/color/size)", path, k))
}
switch kind {
case "bool":
if _, ok := v.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为 boolean", path, k))
}
case "string":
if _, ok := v.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为字符串(如 \"#FF0000\")", path, k))
}
case "number":
if _, ok := v.(float64); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为数字", path, k))
}
}
}
return nil
}
func sheetValidateRichTextItem(item map[string]any, path string) error {
typeRaw, ok := item["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/link/attachment/image)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=text 必须包含 text 字符串字段", path))
}
case "link":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含 text 字符串字段(显示文字)", path))
}
if s, ok := item["link"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含非空 link 字符串字段(超链接 URL)", path))
}
case "attachment":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含 text 字符串字段(显示文件名)", path))
}
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["mimeType"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 mimeType 字符串字段", path))
}
case "image":
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["resourceUrl"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceUrl 字符串字段", path))
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text/link/attachment/image)", path, typeVal))
}
if styleRaw, exists := item["style"]; exists {
if typeVal != "text" && typeVal != "link" {
return apperrors.NewValidation(fmt.Sprintf("%s: style 字段仅 type=text / link 子项支持", path))
}
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
return nil
}
func sheetValidateCellHyperlink(raw any, path string) error {
if raw == nil {
return nil
}
hyperlink, ok := raw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object 或 null", path))
}
typeRaw, ok := hyperlink["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: path / sheet / range / none)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "path", "sheet", "range":
linkRaw, ok := hyperlink["link"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 link 字段", path))
}
link, ok := linkRaw.(string)
if !ok || strings.TrimSpace(link) == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.link 必须为非空字符串", path))
}
if textRaw, exists := hyperlink["text"]; exists {
if _, ok := textRaw.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串", path))
}
}
case "none":
// type=none 表示显式清除单元格级超链接,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: path / sheet / range / none)", path, typeVal))
}
return nil
}
func sheetValidateDataValidation(dvRaw any, path string) error {
dv, ok := dvRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object", path))
}
dvType, ok := dv["type"].(string)
if !ok || dvType == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为非空字符串(合法值: dropdown / checkbox / none)", path))
}
switch dvType {
case "dropdown":
optionsRaw, ok := dv["options"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=dropdown 必须包含 options 数组", path))
}
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.options 必须为非空数组", path))
}
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i))
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d].value 必须为非空字符串", path, i))
}
}
case "checkbox":
if c, exists := dv["checked"]; exists {
if _, ok := c.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.checked 必须为 boolean", path))
}
}
case "none":
// type=none 表示显式清除单元格 DV,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: dropdown / checkbox / none)", path, dvType))
}
return nil
}
// sheet range read 回读投影:MCP 框架会按 schema 把 dataValidation / hyperlink
// 填成"全字段 null"的空壳,导致清除后回读仍带着这些 key。与 wukong
// callMCPToolCellInfos 一致:递归找到 cells,把全 null 的空壳 delete 掉,
// 让"清除后回读不应再有该 key"的语义成立。
func sheetCleanCellInfos(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
sheetStripCellsMeta(cellsRaw)
}
for _, child := range t {
sheetCleanCellInfos(child)
}
case []any:
for _, child := range t {
sheetCleanCellInfos(child)
}
}
}
// sheetProjectFlatValues walks an MCP get_cell_infos response and, next to any
// rich `cells` array, adds a flat `values` 2D array of each cell's scalar
// `value`. This gives consumers the simple wukong get_range shape
// (values: [["姓名","部门"]]) without dropping the rich `cells` payload.
// Idempotent: skips when `values` already exists or there are no cells.
func sheetProjectFlatValues(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
if _, exists := t["values"]; !exists {
if flat := sheetFlatValuesFromCells(cellsRaw); flat != nil {
t["values"] = flat
}
}
}
for _, child := range t {
sheetProjectFlatValues(child)
}
case []any:
for _, child := range t {
sheetProjectFlatValues(child)
}
}
}
func sheetFlatValuesFromCells(cellsRaw any) []any {
rows, ok := cellsRaw.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(rows))
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
return nil
}
flatRow := make([]any, 0, len(rowSlice))
for _, cell := range rowSlice {
if cellMap, ok := cell.(map[string]any); ok {
flatRow = append(flatRow, cellMap["value"])
} else {
flatRow = append(flatRow, cell)
}
}
out = append(out, flatRow)
}
return out
}
func sheetStripCellsMeta(cellsRaw any) {
rows, ok := cellsRaw.([]any)
if !ok {
return
}
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
continue
}
for _, cell := range rowSlice {
cellMap, ok := cell.(map[string]any)
if !ok {
continue
}
if dv, ok := cellMap["dataValidation"]; ok && sheetIsEmptyMetaShell(dv) {
delete(cellMap, "dataValidation")
}
if hl, ok := cellMap["hyperlink"]; ok && sheetIsEmptyMetaShell(hl) {
delete(cellMap, "hyperlink")
}
}
}
}
// sheet info 坐标投影:只向 agent 暴露 A1/UI 语义的 nonEmptyRange
// (range / lastCell / lastRow / lastColumn),并清掉服务端的 legacy 0-based
// 字段。与 wukong normalizeSheetInfoCoordinatesForAgent 一致。
func sheetNormalizeInfoCoordinates(v any) {
switch t := v.(type) {
case map[string]any:
_, hasNonEmpty := t["nonEmptyRange"]
_, hasLegacyRow := t["lastNonEmptyRow"]
if hasNonEmpty || hasLegacyRow {
sheetApplyNonEmptyRange(t)
}
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
case []any:
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
}
}
func sheetApplyNonEmptyRange(sheet map[string]any) {
if ner := sheetNormalizeNonEmptyRangeObject(sheet["nonEmptyRange"]); ner != nil {
sheet["nonEmptyRange"] = ner
} else if ner := sheetBuildNonEmptyRangeFromLegacy(sheet); ner != nil {
sheet["nonEmptyRange"] = ner
} else {
sheet["nonEmptyRange"] = nil
}
delete(sheet, "lastNonEmptyRow")
delete(sheet, "lastNonEmptyColumn")
delete(sheet, "lastNonEmptyIndexBase")
delete(sheet, "lastNonEmptyRowNumber")
delete(sheet, "lastNonEmptyColumnLetter")
delete(sheet, "nonEmptyRangeA1")
}
func sheetNormalizeNonEmptyRangeObject(v any) map[string]any {
ner, ok := v.(map[string]any)
if !ok {
return nil
}
rangeValue, hasRange := ner["range"].(string)
lastCell, hasLastCell := ner["lastCell"].(string)
lastRow, hasLastRow := sheetNonNegativeJSONInt(ner["lastRow"])
lastColumn, hasLastColumn := ner["lastColumn"].(string)
if hasRange && hasLastCell && hasLastRow && hasLastColumn {
return map[string]any{
"range": rangeValue,
"lastCell": lastCell,
"lastRow": lastRow,
"lastColumn": lastColumn,
}
}
return nil
}
func sheetBuildNonEmptyRangeFromLegacy(sheet map[string]any) map[string]any {
row, hasRow := sheetNonNegativeJSONInt(sheet["lastNonEmptyRow"])
col, hasCol := sheetNonNegativeJSONInt(sheet["lastNonEmptyColumn"])
if !hasRow || !hasCol {
return nil
}
lastColumnLetter := sheetColumnLetterFromZeroBased(col)
lastRowNumber := row + 1
lastCellA1 := fmt.Sprintf("%s%d", lastColumnLetter, lastRowNumber)
return map[string]any{
"range": "A1:" + lastCellA1,
"lastCell": lastCellA1,
"lastRow": lastRowNumber,
"lastColumn": lastColumnLetter,
}
}
func sheetNonNegativeJSONInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, n >= 0
case int64:
if n < 0 {
return 0, false
}
return int(n), true
case float64:
if n < 0 {
return 0, false
}
i := int(n)
if float64(i) != n {
return 0, false
}
return i, true
case json.Number:
i, err := n.Int64()
if err != nil || i < 0 {
return 0, false
}
return int(i), true
default:
return 0, false
}
}
func sheetColumnLetterFromZeroBased(index int) string {
if index < 0 {
return ""
}
index++
var b strings.Builder
for index > 0 {
index--
b.WriteByte(byte('A' + index%26))
index /= 26
}
letters := []byte(b.String())
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
letters[i], letters[j] = letters[j], letters[i]
}
return string(letters)
}
// sheetIsEmptyMetaShell 判断 metadata 块是否为全 null 空壳(MCP 框架按 schema 填充的)。
func sheetIsEmptyMetaShell(v any) bool {
if v == nil {
return true
}
m, ok := v.(map[string]any)
if !ok {
return false
}
for _, vv := range m {
if vv != nil {
return false
}
}
return true
}
func sheetValidateComplexValueCell(cell map[string]any, path string) error {
if dvRaw, exists := cell["dataValidation"]; exists {
if err := sheetValidateDataValidation(dvRaw, path+".dataValidation"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists {
if err := sheetValidateCellHyperlink(hyperlinkRaw, path+".hyperlink"); err != nil {
return err
}
}
typeRaw, hasType := cell["type"]
// 没有 type 时,必须有 metadata 字段(不写值,只更新元数据)
if !hasType {
_, hasDV := cell["dataValidation"]
_, hasCS := cell["cellStyles"]
_, hasHyperlink := cell["hyperlink"]
if hasDV || hasCS || hasHyperlink {
return nil
}
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/richText)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
var cellText string
hasCellText := false
if t, exists := cell["text"]; exists {
textValue, ok := t.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串(text=\"\" 表示清空 cell)", path))
}
cellText = textValue
hasCellText = true
}
if styleRaw, exists := cell["style"]; exists {
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists && hyperlinkRaw != nil {
hyperlink, _ := hyperlinkRaw.(map[string]any)
if hyperlinkText, ok := hyperlink["text"].(string); ok && hasCellText && hyperlinkText != cellText {
return apperrors.NewValidation(fmt.Sprintf("%s.hyperlink.text 与 %s.text 不一致,请只传 cell.text 或保持两者相同", path, path))
}
}
case "richText":
if _, hasHyperlink := cell["hyperlink"]; hasHyperlink {
return apperrors.NewValidation(fmt.Sprintf("%s: cell-level hyperlink 不能与 type=richText 同时使用;整格链接用 hyperlink,片段链接用 richText.texts[].type=link", path))
}
textsRaw, ok := cell["texts"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=richText 必须包含 texts 数组", path))
}
texts, ok := textsRaw.([]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 必须为数组", path))
}
if len(texts) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 不能为空数组", path))
}
for i, item := range texts {
itemMap, ok := item.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts[%d] 必须为 object", path, i))
}
if err := sheetValidateRichTextItem(itemMap, fmt.Sprintf("%s.texts[%d]", path, i)); err != nil {
return err
}
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text / richText;不再支持 number/boolean/null,数字布尔请用 {type:text,text:\"...\"} 字符串形式)", path, typeVal))
}
return nil
}