mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
fix(sheets): normalize irregular chart ranges
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# 柱形图:默认放在数据范围右侧
|
||||
|
||||
Reference in New Issue
Block a user