* fix: surface invalid sheet and todo targets * docs: record invalid target fixes * fix: expose todo attachment listing schema * fix: make Windows helper coverage portable * test: run quality regressions in platform coverage
218 lines
5.0 KiB
Go
218 lines
5.0 KiB
Go
package helpers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
func callMCPToolSheetInfo(toolArgs map[string]any) error {
|
|
if deps.Caller.DryRun() {
|
|
return callMCPTool("get_sheet", toolArgs)
|
|
}
|
|
text, err := callMCPToolReturnText(context.Background(), "get_sheet", toolArgs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
|
deps.Out.PrintRaw(text)
|
|
return nil
|
|
}
|
|
normalizeSheetInfoCoordinatesForAgent(parsed)
|
|
return deps.Out.PrintJSON(parsed)
|
|
}
|
|
|
|
func normalizeSheetInfoCoordinatesForAgent(sheet map[string]any) {
|
|
if nonEmptyRange := normalizeNonEmptyRangeObject(sheet["nonEmptyRange"]); nonEmptyRange != nil {
|
|
sheet["nonEmptyRange"] = nonEmptyRange
|
|
} else if nonEmptyRange := buildNonEmptyRangeFromLegacy(sheet); nonEmptyRange != nil {
|
|
sheet["nonEmptyRange"] = nonEmptyRange
|
|
} else {
|
|
sheet["nonEmptyRange"] = nil
|
|
}
|
|
|
|
delete(sheet, "lastNonEmptyRow")
|
|
delete(sheet, "lastNonEmptyColumn")
|
|
delete(sheet, "lastNonEmptyIndexBase")
|
|
delete(sheet, "lastNonEmptyRowNumber")
|
|
delete(sheet, "lastNonEmptyColumnLetter")
|
|
delete(sheet, "nonEmptyRangeA1")
|
|
}
|
|
|
|
func buildNonEmptyRangeFromLegacy(sheet map[string]any) map[string]any {
|
|
row, hasRow := nonNegativeJSONInt(sheet["lastNonEmptyRow"])
|
|
col, hasCol := nonNegativeJSONInt(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 normalizeNonEmptyRangeObject(v any) map[string]any {
|
|
nonEmptyRange, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
rangeValue, hasRange := nonEmptyRange["range"].(string)
|
|
lastCell, hasLastCell := nonEmptyRange["lastCell"].(string)
|
|
lastRow, hasLastRow := nonNegativeJSONInt(nonEmptyRange["lastRow"])
|
|
lastColumn, hasLastColumn := nonEmptyRange["lastColumn"].(string)
|
|
if hasRange && hasLastCell && hasLastRow && hasLastColumn {
|
|
return map[string]any{
|
|
"range": rangeValue,
|
|
"lastCell": lastCell,
|
|
"lastRow": lastRow,
|
|
"lastColumn": lastColumn,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func nonNegativeJSONInt(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)
|
|
}
|
|
|
|
// callMCPToolCellInfos 调用 get_cell_infos 并清理 MCP 框架填充的空元数据壳。
|
|
func callMCPToolCellInfos(toolArgs map[string]any) error {
|
|
text, err := callMCPToolReturnText(context.Background(), "get_cell_infos", toolArgs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
|
// 非 JSON,原样输出
|
|
deps.Out.PrintRaw(text)
|
|
return nil
|
|
}
|
|
if parsed == nil {
|
|
return &CLIError{
|
|
Code: CodeMCPToolError,
|
|
Message: "get_cell_infos returned a null response",
|
|
Suggestion: "请确认 --node 指向当前用户可访问的钉钉在线表格",
|
|
}
|
|
}
|
|
// 清理空 dataValidation / hyperlink
|
|
if cells, ok := parsed["cells"].([]any); ok {
|
|
for _, row := range cells {
|
|
rowSlice, ok := row.([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, cell := range rowSlice {
|
|
cellMap, ok := cell.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
dv, exists := cellMap["dataValidation"]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if isEmptyDataValidation(dv) {
|
|
delete(cellMap, "dataValidation")
|
|
}
|
|
hyperlink, exists := cellMap["hyperlink"]
|
|
if exists && isEmptyHyperlink(hyperlink) {
|
|
delete(cellMap, "hyperlink")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return deps.Out.PrintJSON(parsed)
|
|
}
|
|
|
|
// isEmptyDataValidation 判断 dataValidation 是否为全 null 的空壳(MCP 框架按 schema 填充的)。
|
|
func isEmptyDataValidation(dv any) bool {
|
|
if dv == nil {
|
|
return true
|
|
}
|
|
dvMap, ok := dv.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, v := range dvMap {
|
|
if v != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isEmptyHyperlink 判断 hyperlink 是否为全 null 的空壳(MCP 框架按 schema 填充的)。
|
|
func isEmptyHyperlink(hyperlink any) bool {
|
|
if hyperlink == nil {
|
|
return true
|
|
}
|
|
hyperlinkMap, ok := hyperlink.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, v := range hyperlinkMap {
|
|
if v != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|