--ranges validated the position of "!" in the raw string and then returned the trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range / clear_range operation carrying sheetId: "". Depending on how the server treats an empty sheetId, the whole batch_update fails, or — worse — the operation lands on the default worksheet instead of the one the user named, while the command reports success. Both halves must now be non-empty *after* trimming. batch-clear grew the same hole independently (it duplicated the split inline); it now shares splitSheetPrefixedRange, so the invariant holds by construction rather than by being repeated correctly in two places. batch-set-style --batch had the same gap at the JSON level: it only rejected sheetId == "", so " " passed. It now judges the trimmed value but still sends the raw one — sheetId may be a worksheet *name*, and names may legitimately carry leading or trailing spaces, so trimming on the user's behalf would target a different sheet. The --ranges form cannot express such a name anyway, which is what --batch is for. TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the no-normalisation half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
848 lines
34 KiB
Go
848 lines
34 KiB
Go
package helpers
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
)
|
||
|
||
// ============================================================================
|
||
// set-style / batch-set-style
|
||
//
|
||
// 方案 A(对齐飞书 +cells-set-style):样式统一走 set_cell_range 的 cellStyles 路径,
|
||
// 每个单元格产出 {"cellStyles":{...}}(不写值,保留原值);纯样式写入对合并单元格安全
|
||
// (底层 setComplexValues 仅对写值 cell 拦截合并冲突)。
|
||
// ============================================================================
|
||
|
||
// parseA1Range 解析 A1 表示法(支持 "Sheet1!A1:B3" / "A1:B3" / "A1"),返回 rows/cols。
|
||
func parseA1Range(addr string) (rows, cols int, err error) {
|
||
if i := strings.Index(addr, "!"); i >= 0 {
|
||
addr = addr[i+1:]
|
||
}
|
||
addr = strings.TrimSpace(strings.ToUpper(addr))
|
||
if addr == "" {
|
||
return 0, 0, fmt.Errorf("range 不能为空")
|
||
}
|
||
parts := strings.SplitN(addr, ":", 2)
|
||
c1, r1, err := parseA1Cell(parts[0])
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
c2, r2 := c1, r1
|
||
if len(parts) == 2 {
|
||
c2, r2, err = parseA1Cell(parts[1])
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
}
|
||
if c2 < c1 {
|
||
c1, c2 = c2, c1
|
||
}
|
||
if r2 < r1 {
|
||
r1, r2 = r2, r1
|
||
}
|
||
return r2 - r1 + 1, c2 - c1 + 1, nil
|
||
}
|
||
|
||
func parseA1Cell(s string) (col, row int, err error) {
|
||
var i int
|
||
for i = 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if c < 'A' || c > 'Z' {
|
||
break
|
||
}
|
||
col = col*26 + int(c-'A'+1)
|
||
}
|
||
if i == 0 || i == len(s) {
|
||
return 0, 0, fmt.Errorf("无效单元格地址: %s", s)
|
||
}
|
||
for _, c := range s[i:] {
|
||
if c < '0' || c > '9' {
|
||
return 0, 0, fmt.Errorf("无效单元格地址: %s", s)
|
||
}
|
||
row = row*10 + int(c-'0')
|
||
}
|
||
if row == 0 || col == 0 {
|
||
return 0, 0, fmt.Errorf("无效单元格地址: %s", s)
|
||
}
|
||
return col, row, nil
|
||
}
|
||
|
||
func maxColLenStr(m [][]string) int {
|
||
max := 0
|
||
for _, row := range m {
|
||
if len(row) > max {
|
||
max = len(row)
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
func maxColLen2D(m [][]int) int {
|
||
max := 0
|
||
for _, row := range m {
|
||
if len(row) > max {
|
||
max = len(row)
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
func checkMatrixShape(gotRows, gotCols, wantRows, wantCols int, flagName string) error {
|
||
if gotRows != wantRows || gotCols != wantCols {
|
||
return fmt.Errorf("--%s 维度与 range 不一致:期望 %d×%d,实际 %d×%d", flagName, wantRows, wantCols, gotRows, gotCols)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var (
|
||
hAlignEnum = map[string]bool{"left": true, "center": true, "right": true, "general": true}
|
||
vAlignEnum = map[string]bool{"top": true, "middle": true, "bottom": true}
|
||
fontWeightEnum = map[string]bool{"bold": true, "normal": true}
|
||
wordWrapEnum = map[string]bool{"overflow": true, "clip": true, "autoWrap": true}
|
||
fontStyleEnum = map[string]bool{"normal": true, "italic": true}
|
||
fontLineEnum = map[string]bool{"none": true, "underline": true, "line-through": true}
|
||
)
|
||
|
||
// styleSpec 描述一次 set-style 调用的样式参数(与 CLI flag / 批次 JSON 对齐)。
|
||
type styleSpec struct {
|
||
BgColor string `json:"bgColor,omitempty"`
|
||
BgColorsJSON string `json:"bgColorsJson,omitempty"`
|
||
FontSize int `json:"fontSize,omitempty"`
|
||
FontSizesJSON string `json:"fontSizesJson,omitempty"`
|
||
HAlign string `json:"hAlign,omitempty"`
|
||
HAlignsJSON string `json:"hAlignsJson,omitempty"`
|
||
VAlign string `json:"vAlign,omitempty"`
|
||
VAlignsJSON string `json:"vAlignsJson,omitempty"`
|
||
FontColor string `json:"fontColor,omitempty"`
|
||
FontColorsJSON string `json:"fontColorsJson,omitempty"`
|
||
FontWeight string `json:"fontWeight,omitempty"`
|
||
FontWeightsJSON string `json:"fontWeightsJson,omitempty"`
|
||
WordWrap string `json:"wordWrap,omitempty"`
|
||
NumberFormat string `json:"numberFormat,omitempty"`
|
||
// 字体样式扩展(whole-range 标量)
|
||
FontStyle string `json:"fontStyle,omitempty"` // normal / italic
|
||
FontLine string `json:"fontLine,omitempty"` // none / underline / line-through
|
||
FontFamily string `json:"fontFamily,omitempty"` // 字体族名,如 Arial
|
||
// 四边边框 JSON(whole-range,per-cell 应用=网格),形如
|
||
// {"top":{"style":"solid","color":"#000"},"bottom":{...},"left":{...},"right":{...}}
|
||
BorderStylesJSON string `json:"borderStylesJson,omitempty"`
|
||
}
|
||
|
||
// borderStyleEnum 为引擎 BorderStyle 枚举(粗细已含在 style 内:solid=细/medium=中/thick=粗)。
|
||
var borderStyleEnum = map[string]bool{
|
||
"none": true, "dotted": true, "dashed": true, "solid": true, "medium": true,
|
||
"thick": true, "double": true, "hair": true, "dashDotDot": true, "dashDot": true,
|
||
"mediumDashDotDot": true, "slantDashDot": true, "mediumDashDot": true, "mediumDashed": true,
|
||
}
|
||
|
||
var borderEdgeEnum = map[string]bool{"top": true, "bottom": true, "left": true, "right": true}
|
||
|
||
// borderEdgeFields 是每条边的合法字段:服务端 borderStyles 的边对象只有这两个。
|
||
var borderEdgeFields = []string{"style", "color"}
|
||
|
||
// parseBorderStyles 解析 --border-styles-json,校验边名与 style 枚举,返回可注入 cells 的 borderStyles 对象。
|
||
func parseBorderStyles(jsonStr string) (map[string]any, error) {
|
||
if jsonStr == "" {
|
||
return nil, nil
|
||
}
|
||
var raw map[string]map[string]any
|
||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||
return nil, fmt.Errorf("--border-styles-json 解析失败: %w", err)
|
||
}
|
||
if len(raw) == 0 {
|
||
return nil, fmt.Errorf("--border-styles-json 不能为空对象")
|
||
}
|
||
out := map[string]any{}
|
||
for edge, spec := range raw {
|
||
if !borderEdgeEnum[edge] {
|
||
return nil, fmt.Errorf("--border-styles-json 非法边名: %s(合法: top/bottom/left/right)", edge)
|
||
}
|
||
// 每条边只认 style / color。写错的键(colour)或类型不对的 color(123)
|
||
// 此前被静默忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets /
|
||
// --styles 拒绝未知键是同一条不变量——部分丢样式比直接报错难发现得多。
|
||
if err := rejectUnknownFields(spec, "边框每条边", borderEdgeFields); err != nil {
|
||
return nil, fmt.Errorf("--border-styles-json.%s: %w", edge, err)
|
||
}
|
||
styleVal, hasStyle := spec["style"]
|
||
if !hasStyle || styleVal == nil {
|
||
return nil, fmt.Errorf("--border-styles-json.%s 缺少 style", edge)
|
||
}
|
||
style, isStr := styleVal.(string)
|
||
if !isStr {
|
||
return nil, fmt.Errorf("--border-styles-json.%s.style 必须是字符串,实际是 %T", edge, styleVal)
|
||
}
|
||
if style == "" {
|
||
return nil, fmt.Errorf("--border-styles-json.%s 缺少 style", edge)
|
||
}
|
||
if !borderStyleEnum[style] {
|
||
return nil, fmt.Errorf("--border-styles-json.%s.style 非法: %s(合法: solid/medium/thick/dashed/dotted/double/hair/none/...)", edge, style)
|
||
}
|
||
edgeObj := map[string]any{"style": style}
|
||
if colorVal, ok := spec["color"]; ok && colorVal != nil {
|
||
color, isStr := colorVal.(string)
|
||
if !isStr {
|
||
return nil, fmt.Errorf("--border-styles-json.%s.color 必须是字符串,实际是 %T", edge, colorVal)
|
||
}
|
||
if color == "" {
|
||
return nil, fmt.Errorf("--border-styles-json.%s.color 不能为空字符串(不需要颜色就整个省掉该字段)", edge)
|
||
}
|
||
edgeObj["color"] = color
|
||
}
|
||
out[edge] = edgeObj
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// batchItem 是 batch-set-style 批次配置里的一项:嵌入 styleSpec,额外必填 sheetId + range。
|
||
type batchItem struct {
|
||
SheetID string `json:"sheetId"`
|
||
Range string `json:"range"`
|
||
styleSpec
|
||
}
|
||
|
||
// strGrid 返回一个 getter:给定 (i,j) 返回该格的字符串样式值与是否生效。
|
||
// scalar 与 *-json 二选一;scalar 作用整个 range;json 逐格(空串表示该格不设置)。
|
||
func strGrid(scalar, jsonStr, flagName string, enum map[string]bool, rows, cols int) (func(i, j int) (string, bool), error) {
|
||
if scalar != "" && jsonStr != "" {
|
||
return nil, fmt.Errorf("--%s 与 --%s-json 不能同时指定", flagName, flagName)
|
||
}
|
||
if scalar != "" {
|
||
if enum != nil && !enum[scalar] {
|
||
return nil, fmt.Errorf("--%s 枚举非法: %s", flagName, scalar)
|
||
}
|
||
return func(i, j int) (string, bool) { return scalar, true }, nil
|
||
}
|
||
if jsonStr == "" {
|
||
return nil, nil
|
||
}
|
||
var m [][]string
|
||
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
|
||
return nil, fmt.Errorf("--%s-json 解析失败: %w", flagName, err)
|
||
}
|
||
if err := checkMatrixShape(len(m), maxColLenStr(m), rows, cols, flagName+"-json"); err != nil {
|
||
return nil, err
|
||
}
|
||
if enum != nil {
|
||
for _, row := range m {
|
||
for _, v := range row {
|
||
if v != "" && !enum[v] {
|
||
return nil, fmt.Errorf("--%s-json 包含非法枚举值: %s", flagName, v)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return func(i, j int) (string, bool) {
|
||
if i < len(m) && j < len(m[i]) && m[i][j] != "" {
|
||
return m[i][j], true
|
||
}
|
||
return "", false
|
||
}, nil
|
||
}
|
||
|
||
// intGrid 同 strGrid,用于 fontSize(0 表示未设置)。
|
||
func intGrid(scalar int, jsonStr, flagName string, rows, cols int) (func(i, j int) (int, bool), error) {
|
||
if scalar != 0 && jsonStr != "" {
|
||
return nil, fmt.Errorf("--%s 与 --%s-json 不能同时指定", flagName, flagName)
|
||
}
|
||
if scalar != 0 {
|
||
if scalar < 0 {
|
||
return nil, fmt.Errorf("--%s 必须为正整数", flagName)
|
||
}
|
||
return func(i, j int) (int, bool) { return scalar, true }, nil
|
||
}
|
||
if jsonStr == "" {
|
||
return nil, nil
|
||
}
|
||
var m [][]int
|
||
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
|
||
return nil, fmt.Errorf("--%s-json 解析失败: %w", flagName, err)
|
||
}
|
||
if err := checkMatrixShape(len(m), maxColLen2D(m), rows, cols, flagName+"-json"); err != nil {
|
||
return nil, err
|
||
}
|
||
return func(i, j int) (int, bool) {
|
||
if i < len(m) && j < len(m[i]) && m[i][j] != 0 {
|
||
return m[i][j], true
|
||
}
|
||
return 0, false
|
||
}, nil
|
||
}
|
||
|
||
// buildStyleCells 依据 styleSpec 生成 set_cell_range 的 cells 矩阵(每格 {cellStyles:{...}} 或 {} 跳过)。
|
||
func buildStyleCells(spec *styleSpec, rows, cols int) ([][]any, error) {
|
||
if rows <= 0 || cols <= 0 {
|
||
return nil, fmt.Errorf("range 行列数必须大于 0")
|
||
}
|
||
if rows > 1000 {
|
||
return nil, fmt.Errorf("单次样式更新行数上限为 1000(当前 %d 行)", rows)
|
||
}
|
||
if rows*cols > 30000 {
|
||
return nil, fmt.Errorf("单次样式更新单元格总数上限为 30000(当前 %d×%d=%d)", rows, cols, rows*cols)
|
||
}
|
||
|
||
bgGet, err := strGrid(spec.BgColor, spec.BgColorsJSON, "bg-color", nil, rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sizeGet, err := intGrid(spec.FontSize, spec.FontSizesJSON, "font-size", rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hGet, err := strGrid(spec.HAlign, spec.HAlignsJSON, "h-align", hAlignEnum, rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
vGet, err := strGrid(spec.VAlign, spec.VAlignsJSON, "v-align", vAlignEnum, rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fcGet, err := strGrid(spec.FontColor, spec.FontColorsJSON, "font-color", nil, rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fwGet, err := strGrid(spec.FontWeight, spec.FontWeightsJSON, "font-weight", fontWeightEnum, rows, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// whole-range 标量样式
|
||
if spec.WordWrap != "" && !wordWrapEnum[spec.WordWrap] {
|
||
return nil, fmt.Errorf("--word-wrap 枚举非法: %s(合法值: overflow / clip / autoWrap)", spec.WordWrap)
|
||
}
|
||
if spec.FontStyle != "" && !fontStyleEnum[spec.FontStyle] {
|
||
return nil, fmt.Errorf("--font-style 枚举非法: %s(合法值: normal / italic)", spec.FontStyle)
|
||
}
|
||
if spec.FontLine != "" && !fontLineEnum[spec.FontLine] {
|
||
return nil, fmt.Errorf("--font-line 枚举非法: %s(合法值: none / underline / line-through)", spec.FontLine)
|
||
}
|
||
borderObj, err := parseBorderStyles(spec.BorderStylesJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
anySet := bgGet != nil || sizeGet != nil || hGet != nil || vGet != nil || fcGet != nil || fwGet != nil ||
|
||
spec.WordWrap != "" || spec.NumberFormat != "" || spec.FontStyle != "" || spec.FontLine != "" || spec.FontFamily != "" ||
|
||
borderObj != nil
|
||
if !anySet {
|
||
return nil, fmt.Errorf("至少需要指定一个样式参数(--bg-color / --font-size / --h-align / --v-align / --font-color / --font-weight / --word-wrap / --number-format / --font-style / --font-line / --font-family / --border-styles-json 或对应的 *-json 形式)")
|
||
}
|
||
|
||
cells := make([][]any, rows)
|
||
for i := 0; i < rows; i++ {
|
||
row := make([]any, cols)
|
||
for j := 0; j < cols; j++ {
|
||
cs := map[string]any{}
|
||
if bgGet != nil {
|
||
if v, ok := bgGet(i, j); ok {
|
||
cs["backgroundColor"] = v
|
||
}
|
||
}
|
||
if sizeGet != nil {
|
||
if v, ok := sizeGet(i, j); ok {
|
||
cs["fontSize"] = v
|
||
}
|
||
}
|
||
if hGet != nil {
|
||
if v, ok := hGet(i, j); ok {
|
||
cs["horizontalAlignment"] = v
|
||
}
|
||
}
|
||
if vGet != nil {
|
||
if v, ok := vGet(i, j); ok {
|
||
cs["verticalAlignment"] = v
|
||
}
|
||
}
|
||
if fcGet != nil {
|
||
if v, ok := fcGet(i, j); ok {
|
||
cs["fontColor"] = v
|
||
}
|
||
}
|
||
if fwGet != nil {
|
||
if v, ok := fwGet(i, j); ok {
|
||
cs["fontWeight"] = v
|
||
}
|
||
}
|
||
if spec.WordWrap != "" {
|
||
cs["wordWrap"] = spec.WordWrap
|
||
}
|
||
if spec.NumberFormat != "" {
|
||
cs["numberFormat"] = spec.NumberFormat
|
||
}
|
||
if spec.FontStyle != "" {
|
||
cs["fontStyle"] = spec.FontStyle
|
||
}
|
||
// font-line 作为单选:设定 textUnderline / textLineThrough 两个布尔
|
||
switch spec.FontLine {
|
||
case "underline":
|
||
cs["textUnderline"] = true
|
||
cs["textLineThrough"] = false
|
||
case "line-through":
|
||
cs["textUnderline"] = false
|
||
cs["textLineThrough"] = true
|
||
case "none":
|
||
cs["textUnderline"] = false
|
||
cs["textLineThrough"] = false
|
||
}
|
||
if spec.FontFamily != "" {
|
||
cs["fontFamily"] = spec.FontFamily
|
||
}
|
||
|
||
cell := map[string]any{}
|
||
if len(cs) > 0 {
|
||
cell["cellStyles"] = cs
|
||
}
|
||
if borderObj != nil {
|
||
cell["borderStyles"] = borderObj // 整区共用,per-cell 应用(网格)
|
||
}
|
||
row[j] = cell // 空 map = {} 跳过(保留原值)
|
||
}
|
||
cells[i] = row
|
||
}
|
||
return cells, nil
|
||
}
|
||
|
||
// readStyleSpecFromFlags 从 cobra flag 读取样式参数。
|
||
func readStyleSpecFromFlags(cmd *cobra.Command) *styleSpec {
|
||
spec := &styleSpec{}
|
||
spec.BgColor, _ = cmd.Flags().GetString("bg-color")
|
||
spec.BgColorsJSON, _ = cmd.Flags().GetString("bg-colors-json")
|
||
spec.FontSize, _ = cmd.Flags().GetInt("font-size")
|
||
spec.FontSizesJSON, _ = cmd.Flags().GetString("font-sizes-json")
|
||
spec.HAlign, _ = cmd.Flags().GetString("h-align")
|
||
spec.HAlignsJSON, _ = cmd.Flags().GetString("h-aligns-json")
|
||
spec.VAlign, _ = cmd.Flags().GetString("v-align")
|
||
spec.VAlignsJSON, _ = cmd.Flags().GetString("v-aligns-json")
|
||
spec.FontColor, _ = cmd.Flags().GetString("font-color")
|
||
spec.FontColorsJSON, _ = cmd.Flags().GetString("font-colors-json")
|
||
spec.FontWeight, _ = cmd.Flags().GetString("font-weight")
|
||
spec.FontWeightsJSON, _ = cmd.Flags().GetString("font-weights-json")
|
||
spec.WordWrap, _ = cmd.Flags().GetString("word-wrap")
|
||
spec.NumberFormat, _ = cmd.Flags().GetString("number-format")
|
||
spec.FontStyle, _ = cmd.Flags().GetString("font-style")
|
||
spec.FontLine, _ = cmd.Flags().GetString("font-line")
|
||
spec.FontFamily, _ = cmd.Flags().GetString("font-family")
|
||
spec.BorderStylesJSON, _ = cmd.Flags().GetString("border-styles-json")
|
||
return spec
|
||
}
|
||
|
||
// styleFlagNames 是 bindStyleFlags 绑定的全部样式 flag。
|
||
// set-style 与 batch-set-style 共用这套 flag,但 batch 的 --batch 模式下样式来自
|
||
// 配置文件,需要据此判断用户是否传了不会生效的样式 flag。
|
||
// 新增样式 flag 必须同步登记(TestStyleFlagNamesMatchBoundFlags 会盯住漂移)。
|
||
var styleFlagNames = []string{
|
||
"bg-color", "bg-colors-json",
|
||
"border-styles-json",
|
||
"font-color", "font-colors-json",
|
||
"font-family", "font-line",
|
||
"font-size", "font-sizes-json",
|
||
"font-style",
|
||
"font-weight", "font-weights-json",
|
||
"h-align", "h-aligns-json",
|
||
"number-format",
|
||
"v-align", "v-aligns-json",
|
||
"word-wrap",
|
||
}
|
||
|
||
// changedStyleFlags 返回用户显式设置过的样式 flag(带 -- 前缀,顺序稳定)。
|
||
func changedStyleFlags(cmd *cobra.Command) []string {
|
||
var out []string
|
||
for _, name := range styleFlagNames {
|
||
if cmd.Flags().Changed(name) {
|
||
out = append(out, "--"+name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bindStyleFlags 绑定共用的样式 flag。
|
||
// 说明文案不能写死 --range:set-style 用 --range,batch-set-style 用 --ranges。
|
||
func bindStyleFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("bg-color", "", "背景色(#RRGGBB),一键刷满目标区域;与 --bg-colors-json 二选一")
|
||
cmd.Flags().String("bg-colors-json", "", "背景色二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().Int("font-size", 0, "字号,一键刷满目标区域;与 --font-sizes-json 二选一")
|
||
cmd.Flags().String("font-sizes-json", "", "字号二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().String("h-align", "", "水平对齐(left/center/right/general),一键刷满目标区域")
|
||
cmd.Flags().String("h-aligns-json", "", "水平对齐二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().String("v-align", "", "垂直对齐(top/middle/bottom),一键刷满目标区域")
|
||
cmd.Flags().String("v-aligns-json", "", "垂直对齐二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().String("font-color", "", "字体颜色(#RRGGBB),一键刷满目标区域")
|
||
cmd.Flags().String("font-colors-json", "", "字体颜色二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().String("font-weight", "", "字体粗细(bold/normal),一键刷满目标区域")
|
||
cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组,维度需与目标区域一致")
|
||
cmd.Flags().String("word-wrap", "", "换行方式(overflow/clip/autoWrap),整个目标区域共用")
|
||
cmd.Flags().String("number-format", "", "数字格式 code(常用 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss;@ 为文本,适合长数字 ID)")
|
||
cmd.Flags().String("font-style", "", "字体样式(normal/italic),整个目标区域共用")
|
||
cmd.Flags().String("font-line", "", "字体线条(none/underline/line-through),整个目标区域共用")
|
||
cmd.Flags().String("font-family", "", "字体族(如 Arial / 微软雅黑),整个目标区域共用")
|
||
cmd.Flags().String("border-styles-json", "", `四边边框 JSON(整区共用,逐格应用=网格),如 '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"solid"}}';style 取 solid/medium/thick/dashed/dotted/double/none 等(粗细含在 style 内);每条边只接受 style / color 两个键,写错的键或非字符串 color 直接报错`)
|
||
}
|
||
|
||
// newRangeSetStyleCmd 构造 dws sheet range set-style 命令(走 set_cell_range 的 cellStyles 路径)。
|
||
func newRangeSetStyleCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "set-style",
|
||
Short: "设置指定单元格区域的样式",
|
||
Long: `为指定 range 设置背景色 / 字号 / 对齐 / 字体颜色 / 字体粗细 / 换行 / 数字格式 / 斜体 / 下划线删除线 / 字体族。
|
||
底层走 set_cell_range 的 cellStyles(仅设样式、保留原值),纯样式写入可作用于含合并单元格的区域。
|
||
|
||
每个样式维度(bg-color/font-size/h-align/v-align/font-color/font-weight)提供两种用法,二选一:
|
||
--xxx 单值,一键刷整个 range
|
||
--xxx-json 二维 JSON 数组,逐单元格指定,维度需与 --range 一致
|
||
|
||
整区共用的标量样式(无 *-json 形式):
|
||
--word-wrap overflow/clip/autoWrap
|
||
--number-format 数字格式 code
|
||
--font-style normal/italic(斜体)
|
||
--font-line none/underline/line-through(下划线/删除线,单选)
|
||
--font-family 字体族名
|
||
|
||
单次调用建议:行数 ≤ 1000 且行×列 ≤ 30000(服务端硬限)。
|
||
|
||
数字格式(--number-format)传格式 code:
|
||
General 常规;@ 文本;0/0.00 普通数字;#,##0/#,##0.00 千分位;0%/0.00% 百分比;yyyy/m/d 日期;h:mm/h:mm:ss 时间;货币格式如 "¥"#,##0.00。
|
||
商品ID/订单号/手机号等数字形态标识符请设置 @,否则可能转为科学计数法。`,
|
||
Example: ` # 给 A1:B3 打上黄底粗体居中
|
||
dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A1:B3" \
|
||
--bg-color "#FFF2CC" --font-weight bold --h-align center
|
||
|
||
# 斜体 + 下划线 + 指定字体
|
||
dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A1:A10" \
|
||
--font-style italic --font-line underline --font-family "微软雅黑"
|
||
|
||
# 给 C1:C5 逐单元格设置不同背景色
|
||
dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "C1:C5" \
|
||
--bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]'
|
||
|
||
# 长数字标识符按文本展示
|
||
dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" --number-format "@"`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||
return err
|
||
}
|
||
node := mustGetFlag(cmd, "node")
|
||
sheetID := mustGetFlag(cmd, "sheet-id")
|
||
rangeAddr := mustGetFlag(cmd, "range")
|
||
|
||
rows, cols, err := parseA1Range(rangeAddr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
spec := readStyleSpecFromFlags(cmd)
|
||
cells, err := buildStyleCells(spec, rows, cols)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("set_cell_range", map[string]any{
|
||
"nodeId": node,
|
||
"sheetId": sheetID,
|
||
"rangeAddress": rangeAddr,
|
||
"cells": cells,
|
||
})
|
||
},
|
||
}
|
||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||
cmd.Flags().String("range", "", "目标单元格区域地址,如 A1:B3 (必填)")
|
||
bindStyleFlags(cmd)
|
||
DeclareLeafMetadata(cmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "sheet",
|
||
Name: "range_set_style",
|
||
CanonicalPath: "sheet.range_set_style",
|
||
CLIPath: "sheet range set-style",
|
||
PrimaryCLIPath: "sheet range set-style",
|
||
},
|
||
Description: "为指定范围设置背景、字体、对齐、换行、数字格式、斜体/下划线或边框。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_cell_range"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "为指定范围设置背景、字体、对齐、换行、数字格式、斜体/下划线或边框。",
|
||
UseWhen: []string{"需要批量刷样式或数字格式(百分比/货币/日期)时"},
|
||
AvoidWhen: []string{"写单元格值/公式用 range update;多区域样式用 range batch-set-style"},
|
||
Examples: []string{"dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range \"B2:B10\" --number-format \"¥#,##0.00\""},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "range", Property: "rangeAddress"},
|
||
{Name: "sheet-id", Property: "sheetId"},
|
||
},
|
||
},
|
||
})
|
||
return cmd
|
||
}
|
||
|
||
// newRangeBatchSetStyleCmd 构造 dws sheet range batch-set-style 命令。
|
||
// 组装为**一次** batch_update 调用(服务端原子事务),对齐飞书 +cells-batch-set-style。
|
||
func newRangeBatchSetStyleCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "batch-set-style",
|
||
Short: "批量设置样式(服务端原子事务)",
|
||
Long: `批量为多个区域设置样式,CLI 组装为**一次** batch_update 调用,由服务端原子执行
|
||
(底层每项走 set_cell_range 的 cellStyles/borderStyles)。任一项失败默认整批回滚。
|
||
|
||
两种入口,二选一:
|
||
|
||
1) --ranges(对齐飞书 +cells-batch-set-style):同一组样式应用到多个区域,支持跨工作表
|
||
每项必须带工作表前缀(格式 "SheetName!A1:B3"),最多 100 项;样式用与 set-style 相同的 flag。
|
||
|
||
2) --batch <file>(每项可用不同样式):JSON 数组,每个元素为一个批次项
|
||
[
|
||
{
|
||
"sheetId": "Sheet1",
|
||
"range": "A1:B3",
|
||
"bgColor": "#FFF2CC",
|
||
"fontSize": 12,
|
||
"hAlign": "center",
|
||
"fontWeight": "bold",
|
||
"fontStyle": "italic",
|
||
"fontLine": "underline",
|
||
"fontFamily": "Arial",
|
||
"borderStylesJson": "{\"top\":{\"style\":\"solid\"}}",
|
||
"numberFormat": "General"
|
||
},
|
||
{
|
||
"sheetId": "Sheet1",
|
||
"range": "C1:C5",
|
||
"bgColorsJson": "[[\"#FF0000\"],[\"#00FF00\"],[\"#0000FF\"],[\"#FFFF00\"],[\"#FF00FF\"]]"
|
||
}
|
||
]
|
||
|
||
每项执行与 set-style 一致的本地校验(至少一项样式字段 + rows≤1000 + rows×cols≤30000 + 枚举),
|
||
全部校验通过后才下发,避免部分生效。
|
||
批量上限:最多 100 个区域,且**所有区域累计**不超过 200000 个单元格(一次请求要把全部
|
||
单元格矩阵建好,累计量才是真正的峰值约束)。超出请拆成多次调用。
|
||
--continue-on-error 透传给服务端 batch_update:默认严格事务(失败整批回滚),传入后遇失败继续执行其余项。`,
|
||
Example: ` # 同一组样式刷多个区域(可跨工作表),一次原子提交
|
||
dws sheet range batch-set-style --node NODE_ID \
|
||
--ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' \
|
||
--bg-color "#FFF2CC" --font-weight bold --font-family "微软雅黑"
|
||
|
||
# 每项不同样式:用配置文件
|
||
dws sheet range batch-set-style --node NODE_ID --batch ./styles.json
|
||
dws sheet range batch-set-style --node NODE_ID --batch ./styles.json --continue-on-error`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "node"); err != nil {
|
||
return err
|
||
}
|
||
node := mustGetFlag(cmd, "node")
|
||
rangesStr, _ := cmd.Flags().GetString("ranges")
|
||
batchPath, _ := cmd.Flags().GetString("batch")
|
||
if rangesStr == "" && batchPath == "" {
|
||
return fmt.Errorf("--ranges 与 --batch 至少提供一个")
|
||
}
|
||
if rangesStr != "" && batchPath != "" {
|
||
return fmt.Errorf("--ranges 与 --batch 二选一,不能同时指定")
|
||
}
|
||
// --batch 模式每项样式来自配置文件;命令行样式 flag 不会生效,
|
||
// 静默忽略会让用户以为刷上了,所以直接拒。
|
||
if batchPath != "" {
|
||
if used := changedStyleFlags(cmd); len(used) > 0 {
|
||
return fmt.Errorf("--batch 模式下样式来自配置文件,%s 不会生效;请把样式写进配置文件,或改用 --ranges",
|
||
strings.Join(used, " / "))
|
||
}
|
||
}
|
||
|
||
var operations []any
|
||
var err error
|
||
if rangesStr != "" {
|
||
operations, err = buildBatchStyleOpsFromRanges(cmd, rangesStr)
|
||
} else {
|
||
operations, err = buildBatchStyleOpsFromFile(batchPath)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
toolArgs := map[string]any{
|
||
"nodeId": node,
|
||
"operations": operations,
|
||
}
|
||
if continueOnErr, _ := cmd.Flags().GetBool("continue-on-error"); continueOnErr {
|
||
toolArgs["continueOnError"] = true
|
||
}
|
||
return callMCPTool("batch_update", toolArgs)
|
||
},
|
||
}
|
||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||
cmd.Flags().String("ranges", "", `目标区域 JSON 数组,每项须带工作表前缀且最多 100 项,如 '["Sheet1!A1:B2","Sheet2!D1:D10"]'(与 --batch 二选一,同一组样式应用到所有区域)`)
|
||
cmd.Flags().String("batch", "", "批次配置 JSON 文件路径(与 --ranges 二选一,每项可用不同样式)")
|
||
cmd.Flags().Bool("continue-on-error", false, "遇到失败时继续执行其余项(默认严格事务,整批回滚)")
|
||
bindStyleFlags(cmd)
|
||
DeclareLeafMetadata(cmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "sheet",
|
||
Name: "range_batch_set_style",
|
||
CanonicalPath: "sheet.range_batch_set_style",
|
||
CLIPath: "sheet range batch-set-style",
|
||
PrimaryCLIPath: "sheet range batch-set-style",
|
||
},
|
||
Description: "批量为多个区域设置样式,组装为一次 batch_update 原子提交。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "批量为多个区域设置样式,组装为一次 batch_update 原子提交。",
|
||
UseWhen: []string{"同一组样式要刷多个区域(可跨工作表),或多个区域样式不同需一次原子提交时"},
|
||
AvoidWhen: []string{"单一区域统一样式用 range set-style"},
|
||
Examples: []string{"dws sheet range batch-set-style --node <NODE_ID> --ranges '[\"Sheet1!A1:B2\"]' --bg-color \"#FFF2CC\""},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
return cmd
|
||
}
|
||
|
||
// maxBatchStyleRanges 与飞书 +cells-batch-set-style 的 --ranges 上限一致。
|
||
const maxBatchStyleRanges = 100
|
||
|
||
// maxBatchStyleCells 是一次 fan-out batch 累计可物化的单元格上限(对齐飞书
|
||
// maxStampMatrixCells / checkBatchStampBudget)。batch 会把每个 range 的 cells
|
||
// 矩阵全部建好再序列化成一个请求,所以真正的峰值约束是**跨 range 的总和**,
|
||
// 而不是 buildStyleCells 里的单区域上限:100 个区域各 30000 格 = 300 万个
|
||
// cell map。单区域上限继续由 buildStyleCells 按服务端硬限(30000)把关。
|
||
const maxBatchStyleCells = 200000
|
||
|
||
// addBatchStyleCells 累加待物化的单元格数,超限即报错。
|
||
// 必须在 buildStyleCells 分配矩阵**之前**调用,否则超限的那份已经建出来了。
|
||
func addBatchStyleCells(total *int64, rows, cols int) error {
|
||
*total += int64(rows) * int64(cols)
|
||
if *total > maxBatchStyleCells {
|
||
return fmt.Errorf("所有区域累计展开 %d 个单元格,超过 %d 的安全上限;请减少区域数量或缩小区域范围", *total, maxBatchStyleCells)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// splitSheetPrefixedRange 拆分 "SheetName!A1:B3" → (sheetName, rangeAddr)。
|
||
// 两侧都必须在**修剪之后**仍非空:只按原始串里 ! 的位置判断会放过 " !A1:B2",
|
||
// 修剪后工作表名成了空串,操作却照样带着 sheetId:"" 提交 —— 服务端要么让整批
|
||
// batch_update 失败,要么更危险地落到默认工作表而不是用户指定的那张表。
|
||
func splitSheetPrefixedRange(rng string, idx int) (string, string, error) {
|
||
i := strings.Index(rng, "!")
|
||
if i < 0 {
|
||
return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng)
|
||
}
|
||
sheetName := strings.TrimSpace(rng[:i])
|
||
rangeAddr := strings.TrimSpace(rng[i+1:])
|
||
if sheetName == "" || rangeAddr == "" {
|
||
return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng)
|
||
}
|
||
return sheetName, rangeAddr, nil
|
||
}
|
||
|
||
// buildBatchStyleOpsFromRanges 把「一组样式 + 多个带前缀 range」翻成 batch_update 的 operations。
|
||
func buildBatchStyleOpsFromRanges(cmd *cobra.Command, rangesStr string) ([]any, error) {
|
||
var ranges []string
|
||
if err := json.Unmarshal([]byte(rangesStr), &ranges); err != nil {
|
||
return nil, fmt.Errorf("--ranges JSON 解析失败: %w\n hint: --ranges 必须是 JSON 字符串数组,如 '[\"Sheet1!A1:B3\"]'", err)
|
||
}
|
||
if len(ranges) == 0 {
|
||
return nil, fmt.Errorf("--ranges 不能为空数组")
|
||
}
|
||
if len(ranges) > maxBatchStyleRanges {
|
||
return nil, fmt.Errorf("--ranges 最多 %d 项,当前 %d 项", maxBatchStyleRanges, len(ranges))
|
||
}
|
||
spec := readStyleSpecFromFlags(cmd)
|
||
operations := make([]any, 0, len(ranges))
|
||
var totalCells int64
|
||
for i, rng := range ranges {
|
||
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows, cols, err := parseA1Range(rangeAddr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--ranges[%d] (%q) 解析失败: %w", i, rng, err)
|
||
}
|
||
if err := addBatchStyleCells(&totalCells, rows, cols); err != nil {
|
||
return nil, fmt.Errorf("--ranges[%d] (%q): %w", i, rng, err)
|
||
}
|
||
cells, err := buildStyleCells(spec, rows, cols)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--ranges[%d] (%q): %w", i, rng, err)
|
||
}
|
||
operations = append(operations, map[string]any{
|
||
"toolName": "set_cell_range",
|
||
"input": map[string]any{
|
||
"sheetId": sheetName,
|
||
"rangeAddress": rangeAddr,
|
||
"cells": cells,
|
||
},
|
||
})
|
||
}
|
||
return operations, nil
|
||
}
|
||
|
||
// buildBatchStyleOpsFromFile 把配置文件里的多条「range + 各自样式」翻成 batch_update 的 operations。
|
||
func buildBatchStyleOpsFromFile(batchPath string) ([]any, error) {
|
||
data, err := os.ReadFile(batchPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 --batch 文件失败: %w", err)
|
||
}
|
||
var items []batchItem
|
||
if err := json.Unmarshal(data, &items); err != nil {
|
||
return nil, fmt.Errorf("--batch JSON 解析失败: %w", err)
|
||
}
|
||
if len(items) == 0 {
|
||
return nil, fmt.Errorf("--batch 配置为空")
|
||
}
|
||
total := len(items)
|
||
if total > maxBatchStyleRanges {
|
||
return nil, fmt.Errorf("--batch 最多 %d 条,当前 %d 条", maxBatchStyleRanges, total)
|
||
}
|
||
operations := make([]any, 0, total)
|
||
var totalCells int64
|
||
for i, item := range items {
|
||
// 判空看修剪后的值:" " 这类纯空白与漏填等价,放过去就是一次 sheetId 为
|
||
// 空白的 set_cell_range。但下发仍用原值:sheetId 可以是工作表**名**,名字
|
||
// 允许带首尾空格,这里替用户修剪就会指向另一张表(或找不到表)。--ranges
|
||
// 那条路径受 "Name!A1" 的书写形式所限只能修剪,需要精确名字时请走 --batch。
|
||
if strings.TrimSpace(item.SheetID) == "" || strings.TrimSpace(item.Range) == "" {
|
||
return nil, fmt.Errorf("--batch 第 %d/%d 条缺少 sheetId 或 range", i+1, total)
|
||
}
|
||
rows, cols, err := parseA1Range(item.Range)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--batch 第 %d/%d 条 range 解析失败: %w", i+1, total, err)
|
||
}
|
||
if err := addBatchStyleCells(&totalCells, rows, cols); err != nil {
|
||
return nil, fmt.Errorf("--batch 第 %d/%d 条: %w", i+1, total, err)
|
||
}
|
||
spec := item.styleSpec
|
||
cells, err := buildStyleCells(&spec, rows, cols)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--batch 第 %d/%d 条样式校验失败: %w", i+1, total, err)
|
||
}
|
||
operations = append(operations, map[string]any{
|
||
"toolName": "set_cell_range",
|
||
"input": map[string]any{
|
||
"sheetId": item.SheetID,
|
||
"rangeAddress": item.Range,
|
||
"cells": cells,
|
||
},
|
||
})
|
||
}
|
||
return operations, nil
|
||
}
|