diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 2bffe8740..bdb839ee9 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -3359,7 +3359,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "One contiguous A1 range including headers, or comma-separated aligned ranges preserved as independent refs without gap columns" + "desc": "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle" }, { "name": "data-direction", diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 52c8fa7de..a74e1ead6 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -246,7 +246,7 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "chart-type", Kind: "own", Type: "string", Required: "required", Desc: "Chart type", Enum: []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}}, - {Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated aligned ranges preserved as independent refs without gap columns"}, + {Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"}, {Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}}, {Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"}, {Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"}, diff --git a/shortcuts/sheets/lark_sheet_chart.go b/shortcuts/sheets/lark_sheet_chart.go index 8b91e5e65..f40da4785 100644 --- a/shortcuts/sheets/lark_sheet_chart.go +++ b/shortcuts/sheets/lark_sheet_chart.go @@ -6,6 +6,7 @@ package sheets import ( "context" "regexp" + "strconv" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -151,7 +152,7 @@ func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[s if direction == "" { direction = "column" } - dimensionCount, dataPointCount, err := validateBasicChartDataRanges(dataRange, direction) + normalizedDataRange, dimensionCount, dataPointCount, err := normalizeBasicChartDataRanges(dataRange, direction) if err != nil { return nil, err } @@ -164,7 +165,7 @@ func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[s basic := map[string]interface{}{ "chart_type": chartType, - "data_range": dataRange, + "data_range": normalizedDataRange, } if rt.Changed("data-direction") { basic["data_direction"] = rt.Str("data-direction") @@ -250,56 +251,78 @@ type chartDataRange struct { rowCount, colCount int } -func validateBasicChartDataRanges(dataRange, direction string) (dimensionCount, dataPointCount int, err error) { +func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, err error) { ranges, err := splitChartDataRanges(dataRange) if err != nil { - return 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err) + return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err) } parsed := make([]chartDataRange, 0, len(ranges)) for _, value := range ranges { item, parseErr := parseChartDataRange(value) if parseErr != nil { - return 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr) + return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr) } parsed = append(parsed, item) } first := parsed[0] explicitSheet := "" spans := make([][2]int, 0, len(parsed)) + aligned := true + minRow, minCol := first.row, first.col + maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount for _, item := range parsed { if item.sheet != "" { if explicitSheet != "" && item.sheet != explicitSheet { - return 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet") + return "", 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet") } explicitSheet = item.sheet } if direction == "row" { if item.col != first.col || item.colCount != first.colCount { - return 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must cover the same columns for --data-direction row") + aligned = false } dimensionCount += item.rowCount spans = append(spans, [2]int{item.row, item.row + item.rowCount}) } else { if item.row != first.row || item.rowCount != first.rowCount { - return 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must cover the same rows for --data-direction column") + aligned = false } dimensionCount += item.colCount spans = append(spans, [2]int{item.col, item.col + item.colCount}) } + minRow = min(minRow, item.row) + minCol = min(minCol, item.col) + maxRow = max(maxRow, item.row+item.rowCount) + maxCol = max(maxCol, item.col+item.colCount) } + overlapping := false for i, current := range spans { for j := 0; j < i; j++ { if current[0] < spans[j][1] && spans[j][0] < current[1] { - return 0, 0, sheetsValidationForFlag("data-range", "--data-range items must not overlap") + overlapping = true } } } + normalized = strings.Join(ranges, ",") + if len(ranges) > 1 && (!aligned || overlapping) { + prefix := "" + if explicitSheet != "" { + prefix = explicitSheet + "!" + } + normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow) + dimensionCount = maxCol - minCol + dataPointCount = maxRow - minRow + if direction == "row" { + dimensionCount, dataPointCount = dataPointCount, dimensionCount + } + return normalized, dimensionCount, dataPointCount, nil + } if direction == "row" { dataPointCount = first.colCount } else { dataPointCount = first.rowCount } - return dimensionCount, dataPointCount, nil + return normalized, dimensionCount, dataPointCount, nil } func splitChartDataRanges(value string) ([]string, error) { diff --git a/shortcuts/sheets/lark_sheet_chart_test.go b/shortcuts/sheets/lark_sheet_chart_test.go index df0234c5f..276ee84b9 100644 --- a/shortcuts/sheets/lark_sheet_chart_test.go +++ b/shortcuts/sheets/lark_sheet_chart_test.go @@ -86,6 +86,49 @@ func TestChartCreateBasic_MultipleAlignedRanges(t *testing.T) { } } +func TestChartCreateBasic_MergesMisalignedOrOverlappingRanges(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {name: "separated rows", input: "'Sheet1'!A1:M1,'Sheet1'!A3:M3", expected: "'Sheet1'!A1:M3"}, + {name: "overlapping columns", input: "A1:B10,B1:C10", expected: "A1:C10"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, ChartCreateBasic, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--chart-type", "line", + "--data-range", tt.input, + }) + input := decodeToolInput(t, body, "manage_chart_object") + basic := input["basic_chart"].(map[string]interface{}) + if basic["data_range"] != tt.expected { + t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], tt.expected) + } + }) + } +} + +func TestChartCreateBasic_RejectsCrossSheetRanges(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, ChartCreateBasic, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--chart-type", "line", + "--data-range", "'A'!A1:A10,'B'!C1:D10", + }) + if err == nil { + t.Fatal("expected cross-sheet ranges to fail") + } +} + func TestChartSemanticShortcuts_InBatchUpdate(t *testing.T) { body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index f601f9ee1..5c5fbf76d 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -140,7 +140,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--chart-type` | string | required | 图表类型(可选值:`column` / `bar` / `line` / `area` / `pie` / `scatter` / `combo` / `radar`) | -| `--data-range` | string | required | 含表头的一个连续 A1 范围,或逗号分隔的多个对齐范围;多范围保留为独立引用,不包含中间间隔列 | +| `--data-range` | string | required | 含表头的一个连续 A1 范围,或逗号分隔的同表多范围;对齐且不重叠时保留独立引用,否则合并为最小包围矩形 | | `--data-direction` | string | optional | 数据系列方向;column 表示首列为类别,row 表示首行为类别(可选值:`column` / `row`)(默认 `column`) | | `--title` | string | optional | 图表标题 | | `--subtitle` | string | optional | 图表副标题 | @@ -233,7 +233,7 @@ _创建/更新的图表属性_ ### `+chart-create-basic` -首列自动作为维度,后续列作为数值系列。饼图只使用第二列数值;散点图以首列为 X、后续列为 Y;组合图以第二列为左轴柱,后续列为右轴折线。数据范围必须包含真实表头;如果类别列与数值列不连续,可以给 `--data-range` 传逗号分隔的多个对齐范围,例如 `"'Sheet1'!A1:A10,'Sheet1'!K1:L10"`。CLI 与服务端会保留为多个独立引用,不会把 B:J 的间隔列纳入图表。column 方向各范围必须覆盖相同行,row 方向必须覆盖相同列;如果数据子集的表头在范围外,改用高级 `+chart-create` 的 detached 模式。 +首列自动作为维度,后续列作为数值系列。饼图只使用第二列数值;散点图以首列为 X、后续列为 Y;组合图以第二列为左轴柱,后续列为右轴折线。数据范围必须包含真实表头;如果类别列与数值列不连续,可以给 `--data-range` 传逗号分隔的多个范围,例如 `"'Sheet1'!A1:A10,'Sheet1'!K1:L10"`。对齐且不重叠的范围会保留为独立引用,不会把 B:J 的间隔列纳入图表;错行、错列或重叠的范围会自动合并为同一工作表内的最小包围矩形。跨工作表范围仍会拒绝。如果数据子集的表头在范围外,改用高级 `+chart-create` 的 detached 模式。 ```bash # 柱形图:默认放在数据范围右侧