mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number) - drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead) - styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator - regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
This commit is contained in:
@@ -510,23 +510,12 @@
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -537,19 +526,22 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "header-style",
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Bold the typed header row (only with --sheets; default true)",
|
||||
"default": "true"
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2079,14 +2071,6 @@
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "header-style",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Bold the header row written from column names (default true)",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
|
||||
@@ -1730,11 +1730,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2765,11 +2766,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -6424,6 +6426,293 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,14 +364,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -381,8 +384,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -392,14 +395,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -420,8 +422,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -932,7 +932,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -941,10 +940,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
|
||||
@@ -67,7 +67,7 @@ var TablePut = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tablePutWrite(ctx, runtime, token, payload, runtime.Bool("header-style"))
|
||||
return tablePutWrite(ctx, runtime, token, payload)
|
||||
},
|
||||
Tips: []string{
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
@@ -182,9 +182,13 @@ func (p *tablePayload) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validColumnType reports whether a column type is one the writer understands.
|
||||
// An empty type is valid and means "type-less" (raw passthrough): the value is
|
||||
// written as-is and Lark Sheets auto-detects its type — see buildTypedCell.
|
||||
// +workbook-create's --values synthesizes an all-type-less payload through this.
|
||||
func validColumnType(t string) bool {
|
||||
switch t {
|
||||
case "string", "number", "date", "bool":
|
||||
case "", "string", "number", "date", "bool":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -203,21 +207,18 @@ func headerOn(s *tableSheetSpec) bool {
|
||||
}
|
||||
|
||||
// buildSheetMatrix turns a sheet spec into the set_cell_range cells matrix:
|
||||
// optionally a (bold-able) header row of column names, then one row per data
|
||||
// record with each cell mapped by its column type. Per-column number_format is
|
||||
// attached so numbers/dates render correctly (and dates become real dates).
|
||||
func buildSheetMatrix(s *tableSheetSpec, headerStyle, writeHeader bool) ([][]interface{}, error) {
|
||||
// optionally a header row of column names, then one row per data record with
|
||||
// each cell mapped by its column type. Per-column number_format is attached so
|
||||
// numbers/dates render correctly (and dates become real dates). Header cells
|
||||
// carry no style of their own — style them via --styles like any other cell.
|
||||
func buildSheetMatrix(s *tableSheetSpec, writeHeader bool) ([][]interface{}, error) {
|
||||
ncols := len(s.Columns)
|
||||
matrix := make([][]interface{}, 0, len(s.Rows)+1)
|
||||
|
||||
if writeHeader {
|
||||
header := make([]interface{}, ncols)
|
||||
for c := range s.Columns {
|
||||
cell := map[string]interface{}{"value": s.Columns[c].Name}
|
||||
if headerStyle {
|
||||
cell["cell_styles"] = map[string]interface{}{"font_weight": "bold"}
|
||||
}
|
||||
header[c] = cell
|
||||
header[c] = map[string]interface{}{"value": s.Columns[c].Name}
|
||||
}
|
||||
matrix = append(matrix, header)
|
||||
}
|
||||
@@ -239,7 +240,9 @@ func buildSheetMatrix(s *tableSheetSpec, headerStyle, writeHeader bool) ([][]int
|
||||
// buildTypedCell maps one raw JSON value to a set_cell_range cell per its
|
||||
// declared column type. A nil (JSON null) becomes an empty cell that still
|
||||
// carries the column's number_format. number values are kept as json.Number to
|
||||
// preserve precision; dates are converted to Excel serials.
|
||||
// preserve precision; dates are converted to Excel serials. A type-less column
|
||||
// (Type == "") writes the raw scalar unchanged, letting the backend auto-detect
|
||||
// the type — the untyped --values path relies on this.
|
||||
func buildTypedCell(col *tableColumnSpec, raw interface{}) (map[string]interface{}, error) {
|
||||
cell := map[string]interface{}{}
|
||||
nf := strings.TrimSpace(col.Format)
|
||||
@@ -261,6 +264,12 @@ func buildTypedCell(col *tableColumnSpec, raw interface{}) (map[string]interface
|
||||
return cell, nil
|
||||
}
|
||||
switch col.Type {
|
||||
case "":
|
||||
// Type-less column: write the raw JSON scalar as-is so Lark Sheets
|
||||
// auto-detects the type (numeric → number, else text). json.Number is
|
||||
// kept verbatim for precision; an optional --styles number_format
|
||||
// controls display. This is the untyped --values behavior.
|
||||
cell["value"] = raw
|
||||
case "string":
|
||||
cell["value"] = stringifyCellValue(raw)
|
||||
case "number":
|
||||
@@ -384,7 +393,7 @@ func tablePutFullRange(s *tableSheetSpec, totalRows int) string {
|
||||
// writeSheetData writes one sheet's matrix via set_cell_range, splitting into
|
||||
// row batches when the cell count would exceed tablePutMaxCellsPerWrite.
|
||||
// Returns a per-sheet summary for the result envelope.
|
||||
func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, s *tableSheetSpec, headerStyle bool) (map[string]interface{}, error) {
|
||||
func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, s *tableSheetSpec, styles *workbookCreateStylePayload) (map[string]interface{}, error) {
|
||||
_, col0, row0, err := sheetAnchor(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -410,10 +419,13 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}
|
||||
}
|
||||
|
||||
matrix, err := buildSheetMatrix(s, headerStyle, writeHeader)
|
||||
matrix, err := buildSheetMatrix(s, writeHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(matrix) == 0 {
|
||||
// header:false with no data rows — nothing to write.
|
||||
@@ -453,6 +465,9 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}
|
||||
writes++
|
||||
}
|
||||
if err := applyWorkbookCreateVisualOps(ctx, runtime, token, sheetID, styles); err != nil {
|
||||
return nil, fmt.Errorf("applying visual styles: %w", err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"sheet_id": sheetID,
|
||||
@@ -514,7 +529,7 @@ func lastDataRow(ctx context.Context, runtime *common.RuntimeContext, token, she
|
||||
//
|
||||
// On failure it returns the summaries written so far alongside the error, so
|
||||
// the caller can surface a partial_success.
|
||||
func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload, headerStyle bool, adoptSheetID string) ([]interface{}, error) {
|
||||
func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload, adoptSheetID string, styles *workbookCreateSheetStyles) ([]interface{}, error) {
|
||||
byName, err := listSheetIDsByName(ctx, runtime, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -545,7 +560,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
}
|
||||
byName[s.Name] = sheetID
|
||||
}
|
||||
summary, err := writeSheetData(ctx, runtime, token, sheetID, s, headerStyle)
|
||||
summary, err := writeSheetData(ctx, runtime, token, sheetID, s, styles.styleFor(i))
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("writing sheet %q failed: %w", s.Name, err)
|
||||
}
|
||||
@@ -570,8 +585,8 @@ func renameSheet(ctx context.Context, runtime *common.RuntimeContext, token, she
|
||||
// tablePutWrite writes the payload into an existing workbook and emits the
|
||||
// +table-put envelope. The shared write loop lives in writeTypedSheets; this
|
||||
// wrapper adds +table-put's output shape and partial-success reporting.
|
||||
func tablePutWrite(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload, headerStyle bool) error {
|
||||
written, err := writeTypedSheets(ctx, runtime, token, payload, headerStyle, "")
|
||||
func tablePutWrite(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload) error {
|
||||
written, err := writeTypedSheets(ctx, runtime, token, payload, "", nil)
|
||||
if err != nil {
|
||||
return tablePutPartial(token, nil, written, err.Error())
|
||||
}
|
||||
@@ -721,10 +736,9 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if err != nil {
|
||||
return dry
|
||||
}
|
||||
headerStyle := runtime.Bool("header-style")
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerStyle, headerOn(s))
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
|
||||
@@ -223,8 +223,8 @@ func TestTablePut_DryRunWrite(t *testing.T) {
|
||||
}
|
||||
rows := input["cells"].([]interface{})
|
||||
header := rows[0].([]interface{})
|
||||
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
|
||||
t.Errorf("header cell should be bold, got %#v", header[0])
|
||||
if hs := cellStyles(header[0]); len(hs) != 0 {
|
||||
t.Errorf("header cell should carry no style now that --header-style is removed, got %#v", header[0])
|
||||
}
|
||||
data := rows[1].([]interface{})
|
||||
// 月份 (date) → serial 45306, number_format yyyy-mm
|
||||
@@ -490,7 +490,6 @@ func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
|
||||
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -597,6 +596,37 @@ func TestWorkbookCreate_TypedDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbookCreate_TypedDryRun_MultiSheetStyles(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Demo",
|
||||
"--sheets", `{"sheets":[{"name":"S1","columns":[{"name":"name","type":"string"}],"rows":[["alice"]]},{"name":"S2","columns":[{"name":"amount","type":"number","format":"0"}],"rows":[[12]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"S1","cell_styles":[{"range":"A1:A2","background_color":"#f5f5f5"}],"cell_merges":[{"range":"A1:A2"}]},{"name":"S2","cell_styles":[{"range":"A1","font_weight":"bold"},{"range":"A2","font_color":"#0f7b0f"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}]}`,
|
||||
})
|
||||
if len(calls) != 6 {
|
||||
t.Fatalf("want 6 dry-run calls (create + two typed writes + merge + two resizes), got %d", len(calls))
|
||||
}
|
||||
firstBody, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
firstInput := decodeToolInput(t, firstBody, "set_cell_range")
|
||||
firstRaw, _ := json.Marshal(firstInput["cells"])
|
||||
if !strings.Contains(string(firstRaw), `"background_color":"#f5f5f5"`) {
|
||||
t.Errorf("first sheet should carry global style; cells=%s", firstRaw)
|
||||
}
|
||||
secondBody, _ := calls[3].(map[string]interface{})["body"].(map[string]interface{})
|
||||
secondInput := decodeToolInput(t, secondBody, "set_cell_range")
|
||||
secondRaw, _ := json.Marshal(secondInput["cells"])
|
||||
if !strings.Contains(string(secondRaw), `"font_weight":"bold"`) || !strings.Contains(string(secondRaw), `"font_color":"#0f7b0f"`) {
|
||||
t.Errorf("second sheet should carry per-cell styles; cells=%s", secondRaw)
|
||||
}
|
||||
allRaw, _ := json.Marshal(calls)
|
||||
if !strings.Contains(string(allRaw), "merge_cells") {
|
||||
t.Errorf("dry-run should include merge_cells visual op; calls=%s", allRaw)
|
||||
}
|
||||
if got := strings.Count(string(allRaw), "resize_range"); got != 2 {
|
||||
t.Errorf("dry-run resize_range count = %d, want 2; calls=%s", got, allRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_StringifyCellValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -658,10 +688,10 @@ func TestTablePut_HeaderAndMode(t *testing.T) {
|
||||
}
|
||||
// buildSheetMatrix header toggle
|
||||
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
|
||||
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
|
||||
if m, _ := buildSheetMatrix(s, false); len(m) != 1 {
|
||||
t.Errorf("header off → 1 data row, got %d", len(m))
|
||||
}
|
||||
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
|
||||
if m, _ := buildSheetMatrix(s, true); len(m) != 2 {
|
||||
t.Errorf("header on → header + 1 data row, got %d", len(m))
|
||||
}
|
||||
}
|
||||
@@ -989,3 +1019,58 @@ func TestTableGet_AllSheets(t *testing.T) {
|
||||
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTypedCell_TypeLess verifies a type-less column (Type == "") writes the
|
||||
// raw scalar unchanged — no @ text format, json.Number preserved — so untyped
|
||||
// --values lets the backend auto-detect types. An explicit format still attaches.
|
||||
func TestBuildTypedCell_TypeLess(t *testing.T) {
|
||||
t.Parallel()
|
||||
num := json.Number("145487")
|
||||
pct := json.Number("0.1")
|
||||
cases := []struct {
|
||||
name string
|
||||
col tableColumnSpec
|
||||
raw interface{}
|
||||
wantVal interface{}
|
||||
wantNF interface{}
|
||||
}{
|
||||
{"number stays json.Number", tableColumnSpec{Name: "c"}, num, num, nil},
|
||||
{"string verbatim", tableColumnSpec{Name: "c"}, "00123", "00123", nil},
|
||||
{"bool verbatim", tableColumnSpec{Name: "c"}, true, true, nil},
|
||||
{"nil → empty cell", tableColumnSpec{Name: "c"}, nil, nil, nil},
|
||||
{"explicit format attaches", tableColumnSpec{Name: "c", Format: "0.0%"}, pct, pct, "0.0%"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell, err := buildTypedCell(&tc.col, tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTypedCell err: %v", err)
|
||||
}
|
||||
if cell["value"] != tc.wantVal {
|
||||
t.Errorf("value = %#v, want %#v", cell["value"], tc.wantVal)
|
||||
}
|
||||
var gotNF interface{}
|
||||
if cs, _ := cell["cell_styles"].(map[string]interface{}); cs != nil {
|
||||
gotNF = cs["number_format"]
|
||||
}
|
||||
if gotNF != tc.wantNF {
|
||||
t.Errorf("number_format = %#v, want %#v", gotNF, tc.wantNF)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidColumnType_AcceptsEmpty locks that an empty type is valid — the
|
||||
// type-less / raw-passthrough column that --values synthesizes.
|
||||
func TestValidColumnType_AcceptsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ty := range []string{"", "string", "number", "date", "bool"} {
|
||||
if !validColumnType(ty) {
|
||||
t.Errorf("validColumnType(%q) = false, want true", ty)
|
||||
}
|
||||
}
|
||||
if validColumnType("float") {
|
||||
t.Error(`validColumnType("float") = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -560,7 +561,7 @@ var SheetHideGridline = newSheetVisibilityShortcut(
|
||||
var WorkbookCreate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+workbook-create",
|
||||
Description: "Create a new spreadsheet, optionally pre-filled with untyped --headers/--values or typed --sheets (type-faithful one-step create + write).",
|
||||
Description: "Create a new spreadsheet, optionally pre-filled with untyped --values or typed --sheets (type-faithful one-step create + write).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -571,42 +572,33 @@ var WorkbookCreate = common.Shortcut{
|
||||
return common.FlagErrorf("--title is required")
|
||||
}
|
||||
// --sheets (typed) is an alternative, mutually exclusive data entry to the
|
||||
// untyped --headers/--values. Gated on Changed (not just non-empty): an
|
||||
// explicitly-given but empty --sheets (e.g. empty stdin / file) is an
|
||||
// error, not a silent fall-through to creating an empty workbook.
|
||||
// untyped --values. Gated on Changed (not just non-empty): an explicitly-
|
||||
// given but empty --sheets (e.g. empty stdin / file) is an error, not a
|
||||
// silent fall-through to creating an empty workbook.
|
||||
if runtime.Changed("sheets") {
|
||||
if strings.TrimSpace(runtime.Str("sheets")) == "" {
|
||||
return common.FlagErrorf("--sheets was given but resolved to empty (empty stdin/file?); pass a typed payload, or drop --sheets to create an empty workbook")
|
||||
}
|
||||
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
|
||||
return common.FlagErrorf("--sheets is mutually exclusive with --headers/--values")
|
||||
if runtime.Str("values") != "" {
|
||||
return common.FlagErrorf("--sheets is mutually exclusive with --values")
|
||||
}
|
||||
_, err := parseTablePutPayload(runtime)
|
||||
payload, err := parseTablePutPayload(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
return err
|
||||
}
|
||||
if runtime.Str("headers") != "" {
|
||||
v, err := parseJSONFlag(runtime, "headers")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
return common.FlagErrorf("--headers must be a JSON array")
|
||||
}
|
||||
// Untyped --values path: parse (and validate) --styles as a single sheet
|
||||
// style item, then synthesize --values into a type-less typed payload —
|
||||
// the same construction buildValuesPayload runs at execute time, so any
|
||||
// malformed --values / --styles is caught here before a workbook is made.
|
||||
sheetStyles, err := parseValuesSheetStyles(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("values") != "" {
|
||||
v, err := parseJSONFlag(runtime, "values")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--values must be a JSON 2D array")
|
||||
}
|
||||
for i, r := range rows {
|
||||
if _, ok := r.([]interface{}); !ok {
|
||||
return common.FlagErrorf("--values[%d] must be an array", i)
|
||||
}
|
||||
}
|
||||
if _, err := buildValuesPayload(runtime, sheetStyles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -619,36 +611,30 @@ var WorkbookCreate = common.Shortcut{
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Desc("create spreadsheet").
|
||||
Body(body)
|
||||
// Typed --sheets path: preview the create POST, then one set_cell_range
|
||||
// write per sheet (the first adopts the new workbook's default sheet).
|
||||
// Mirrors +table-put's dry-run, against a placeholder token.
|
||||
if runtime.Changed("sheets") {
|
||||
if payload, err := parseTablePutPayload(runtime); err == nil {
|
||||
headerStyle := runtime.Bool("header-style")
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerStyle, headerOn(s))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write typed sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
// Both data entries (typed --sheets and untyped --values) resolve to the
|
||||
// same typed payload and preview through the same set_cell_range path: one
|
||||
// write per sheet, the first adopting the new workbook's default sheet.
|
||||
// Mirrors +table-put's dry-run against a placeholder token.
|
||||
payload, sheetStyles, _ := workbookCreateData(runtime)
|
||||
if payload == nil {
|
||||
return dry
|
||||
}
|
||||
if fill, _ := buildInitialFillInput(runtime); fill != nil {
|
||||
fill["excel_id"] = "<new-token>"
|
||||
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
|
||||
wireBody, _ := buildToolBody("set_cell_range", fill)
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc("fill headers + data via set_cell_range (sheet_id resolved after create)").
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
|
||||
}
|
||||
return dry
|
||||
},
|
||||
@@ -672,60 +658,33 @@ var WorkbookCreate = common.Shortcut{
|
||||
|
||||
result := map[string]interface{}{"spreadsheet": ss}
|
||||
|
||||
// Typed --sheets path: write type-faithful data into the brand-new
|
||||
// workbook, adopting its default sheet as the first payload sheet so no
|
||||
// empty "Sheet1" is left behind. Mutually exclusive with --headers/--values
|
||||
// (enforced in Validate).
|
||||
if runtime.Changed("sheets") {
|
||||
payload, err := parseTablePutPayload(runtime)
|
||||
if err != nil {
|
||||
return err // already validated; defensive
|
||||
}
|
||||
// Both data entries resolve to the same typed payload: --sheets directly,
|
||||
// --values synthesized into a type-less payload. Both write through
|
||||
// writeTypedSheets, adopting the brand-new workbook's default sheet as the
|
||||
// first payload sheet so no empty "Sheet1" is left behind.
|
||||
payload, sheetStyles, err := workbookCreateData(runtime)
|
||||
if err != nil {
|
||||
return err // already validated; defensive
|
||||
}
|
||||
if payload != nil {
|
||||
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("resolving its default sheet for the typed write failed: %v", err))
|
||||
fmt.Sprintf("resolving its default sheet for the write failed: %v", err))
|
||||
}
|
||||
written, err := writeTypedSheets(ctx, runtime, token, payload, runtime.Bool("header-style"), firstSheetID)
|
||||
written, err := writeTypedSheets(ctx, runtime, token, payload, firstSheetID, sheetStyles)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("typed write failed: %v", err))
|
||||
fmt.Sprintf("write failed: %v", err))
|
||||
}
|
||||
result["sheets"] = written
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --headers / --values are optional. buildInitialFillInput returns
|
||||
// (nil, nil) when both are absent or empty, in which case we skip the
|
||||
// fill entirely rather than dereferencing a nil map.
|
||||
fill, err := buildInitialFillInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fill != nil {
|
||||
fill["excel_id"] = token
|
||||
// set_cell_range needs a concrete sheet selector; the create
|
||||
// response doesn't echo the default sheet's id, so read it back.
|
||||
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err))
|
||||
}
|
||||
fill["sheet_id"] = firstSheetID
|
||||
fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill)
|
||||
if err != nil {
|
||||
return workbookCreatedButFillFailed(token, ss,
|
||||
fmt.Sprintf("initial fill failed: %v", err))
|
||||
}
|
||||
result["initial_fill"] = fillOut
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.",
|
||||
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --headers/--values; the new workbook's default sheet becomes the first typed sheet (no empty Sheet1 left behind).",
|
||||
"--values is an optional untyped fill (one JSON 2D array). It writes through the same batched set_cell_range path as --sheets; pair it with --styles to set number formats, colors, merges, and row/col sizes. Partial failure leaves the spreadsheet created but empty.",
|
||||
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --values; the new workbook's default sheet becomes the first sheet (no empty Sheet1 left behind).",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -749,62 +708,751 @@ func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason
|
||||
}
|
||||
}
|
||||
|
||||
// buildInitialFillInput zips --headers + --values into a single set_cell_range
|
||||
// payload writing to the first sheet starting at A1.
|
||||
func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
var rows [][]interface{}
|
||||
if runtime.Str("headers") != "" {
|
||||
v, _ := parseJSONFlag(runtime, "headers")
|
||||
headerArr, _ := v.([]interface{})
|
||||
row := make([]interface{}, 0, len(headerArr))
|
||||
for _, h := range headerArr {
|
||||
row = append(row, map[string]interface{}{"value": h})
|
||||
// valuesSheetName is the synthesized sheet name for the untyped --values path.
|
||||
// It matches a freshly created workbook's default sheet, so writeTypedSheets
|
||||
// adopts that sheet in place (no rename, no stray sheet) — see its adopt logic.
|
||||
// Lark Sheets names the default sheet "Sheet1" on create.
|
||||
const valuesSheetName = "Sheet1"
|
||||
|
||||
// workbookCreateData resolves the data to write into a freshly created workbook:
|
||||
// typed --sheets directly, or untyped --values synthesized as a single sheet of
|
||||
// type-less (raw passthrough) columns. Both go through writeTypedSheets so the
|
||||
// two entries share one batched set_cell_range writer. Returns (nil, nil, nil)
|
||||
// when there's nothing to fill (no --sheets, and no --values/--styles extent).
|
||||
func workbookCreateData(runtime *common.RuntimeContext) (*tablePayload, *workbookCreateSheetStyles, error) {
|
||||
if runtime.Changed("sheets") {
|
||||
payload, err := parseTablePutPayload(runtime)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if runtime.Str("values") != "" {
|
||||
v, _ := parseJSONFlag(runtime, "values")
|
||||
dataArr, _ := v.([]interface{})
|
||||
for _, r := range dataArr {
|
||||
cells, _ := r.([]interface{})
|
||||
row := make([]interface{}, 0, len(cells))
|
||||
for _, c := range cells {
|
||||
row = append(row, map[string]interface{}{"value": c})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return payload, styles, nil
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
styles, err := parseValuesSheetStyles(runtime)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
payload, err := buildValuesPayload(runtime, styles)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return payload, styles, nil
|
||||
}
|
||||
|
||||
// parseValuesSheetStyles parses --styles for the untyped --values path and wraps
|
||||
// the single style item as a one-sheet workbookCreateSheetStyles, so --values
|
||||
// reuses writeSheetData's styleFor application. The item's name is ignored (the
|
||||
// synthesized sheet is always index 0). Returns nil when --styles is absent.
|
||||
func parseValuesSheetStyles(runtime flagView) (*workbookCreateSheetStyles, error) {
|
||||
p, err := parseWorkbookCreateStyles(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{p}}, nil
|
||||
}
|
||||
|
||||
// buildValuesPayload turns untyped --values into a single-sheet typed payload of
|
||||
// type-less columns (Header=false), so --values shares --sheets' batched
|
||||
// set_cell_range writer. Rows are normalized to a rectangle wide/long enough to
|
||||
// also cover any --styles cell ranges (matching the old buildInitialFillInput,
|
||||
// where a style on B3 extends the written block). Returns (nil, nil) when there
|
||||
// is nothing to write — no --values rows and no style-driven extent.
|
||||
func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles) (*tablePayload, error) {
|
||||
rows, err := parseValuesRows(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxCols := 0
|
||||
for _, r := range rows {
|
||||
if len(r) > maxCols {
|
||||
maxCols = len(r)
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
// --headers '[]' / --values '[]' parse to rows that carry no cells.
|
||||
// There is nothing to write and a 0-width range ("A1:1") would be
|
||||
// illegal, so treat it as "no initial fill" — same contract as the
|
||||
// len(rows)==0 case above — and let the caller skip the write.
|
||||
return nil, nil
|
||||
var styleRows, styleCols int
|
||||
if sheetStyles != nil {
|
||||
styleRows, styleCols = workbookCreateStyleDimensions(sheetStyles.styleFor(0), 0, 0)
|
||||
}
|
||||
if styleCols > maxCols {
|
||||
maxCols = styleCols
|
||||
}
|
||||
nrows := len(rows)
|
||||
if styleRows > nrows {
|
||||
nrows = styleRows
|
||||
}
|
||||
if maxCols == 0 || nrows == 0 {
|
||||
return nil, nil // nothing to write (e.g. --values '[]' with no styles)
|
||||
}
|
||||
// Pad to a rectangle; nil cells become empty cells in buildTypedCell.
|
||||
for len(rows) < nrows {
|
||||
rows = append(rows, nil)
|
||||
}
|
||||
// Normalize rows to the same length so cells matrix is rectangular.
|
||||
for i := range rows {
|
||||
for len(rows[i]) < maxCols {
|
||||
rows[i] = append(rows[i], map[string]interface{}{})
|
||||
rows[i] = append(rows[i], nil)
|
||||
}
|
||||
}
|
||||
endCol := columnIndexToLetter(maxCols - 1)
|
||||
rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows))
|
||||
return map[string]interface{}{
|
||||
"range": rangeStr,
|
||||
"cells": rows,
|
||||
// sheet_id is left for the caller to fill: Execute resolves the new
|
||||
// workbook's first sheet via lookupFirstSheetID. The DryRun preview
|
||||
// can't know it yet (the workbook doesn't exist), so it stays absent.
|
||||
}, nil
|
||||
cols := make([]tableColumnSpec, maxCols)
|
||||
for i := range cols {
|
||||
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
|
||||
}
|
||||
noHeader := false
|
||||
return &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Name: valuesSheetName,
|
||||
Mode: "overwrite",
|
||||
Header: &noHeader,
|
||||
Columns: cols,
|
||||
Rows: rows,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
|
||||
// resolved by the flag layer) using UseNumber so numeric cells keep full
|
||||
// precision (large order IDs survive). Empty --values yields no rows.
|
||||
func parseValuesRows(runtime flagView) ([][]interface{}, error) {
|
||||
raw := strings.TrimSpace(runtime.Str("values"))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v interface{}
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, common.FlagErrorf("--values: invalid JSON: %v", err)
|
||||
}
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--values must be a JSON 2D array")
|
||||
}
|
||||
rows := make([][]interface{}, len(arr))
|
||||
for i, r := range arr {
|
||||
cells, ok := r.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--values[%d] must be an array", i)
|
||||
}
|
||||
rows[i] = cells
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
type workbookCreateStylePayload struct {
|
||||
CellStyles []workbookCreateCellStyleOp
|
||||
RowSizes []workbookCreateResizeOp
|
||||
ColSizes []workbookCreateResizeOp
|
||||
CellMerges []workbookCreateMergeOp
|
||||
}
|
||||
|
||||
type workbookCreateCellStyleOp struct {
|
||||
Range string
|
||||
Style map[string]interface{}
|
||||
}
|
||||
|
||||
type workbookCreateMergeOp struct {
|
||||
Range string
|
||||
MergeType string
|
||||
}
|
||||
|
||||
type workbookCreateResizeOp struct {
|
||||
Range string
|
||||
ResizeType string
|
||||
Size int
|
||||
}
|
||||
|
||||
type workbookCreateSheetStyles struct {
|
||||
ByIndex []*workbookCreateStylePayload
|
||||
ByName map[string]*workbookCreateStylePayload
|
||||
}
|
||||
|
||||
func (s *workbookCreateSheetStyles) styleFor(index int) *workbookCreateStylePayload {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if index >= 0 && index < len(s.ByIndex) && s.ByIndex[index] != nil {
|
||||
return s.ByIndex[index]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseWorkbookCreateStyles parses --styles for +workbook-create's untyped
|
||||
// initial-fill path. The outer protocol is always {"styles":[...]}; untyped
|
||||
// initial fill consumes exactly one item from that array.
|
||||
func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) != 1 {
|
||||
return nil, common.FlagErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
// The outer protocol is always {"styles":[...]}, and the array is aligned with
|
||||
// --sheets.sheets. Each item must name the same sheet at the same index.
|
||||
func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*workbookCreateSheetStyles, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) != len(payload.Sheets) {
|
||||
return nil, common.FlagErrorf("--styles.styles has %d items, want %d to match --sheets.sheets", len(items), len(payload.Sheets))
|
||||
}
|
||||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||||
for i, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, common.FlagErrorf("--styles.styles[%d].name is required", i)
|
||||
}
|
||||
if name != payload.Sheets[i].Name {
|
||||
return nil, common.FlagErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
}
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, error) {
|
||||
root, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--styles must be a JSON object shaped as {\"styles\":[...]}")
|
||||
}
|
||||
rawItems, ok := root["styles"]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--styles.styles is required")
|
||||
}
|
||||
arr, ok := rawItems.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--styles.styles must be an array")
|
||||
}
|
||||
items := make([]map[string]interface{}, len(arr))
|
||||
for i, raw := range arr {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--styles.styles[%d] must be an object", i)
|
||||
}
|
||||
items[i] = item
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var err error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, common.FlagErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.FlagErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return nil, common.FlagErrorf("%s[%d] must include at least one style field", path, i)
|
||||
}
|
||||
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.FlagErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return nil, common.FlagErrorf("%s[%d].merge_type must be a non-empty string", path, i)
|
||||
}
|
||||
mergeType = strings.TrimSpace(v)
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return nil, common.FlagErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.FlagErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.FlagErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.FlagErrorf("%s[%d].type is required (pixel/standard%s)", path, i, autoSuffix(dimension))
|
||||
}
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.FlagErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.FlagErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
n, ok := util.ToFloat64(raw)
|
||||
if !ok || n <= 0 {
|
||||
return nil, common.FlagErrorf("%s[%d].size must be a positive number", path, i)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return nil, common.FlagErrorf("%s[%d].type pixel requires size", path, i)
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return nil, common.FlagErrorf("%s[%d].size is only valid with type pixel", path, i)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
rangeRaw, ok := op["range"]
|
||||
if !ok {
|
||||
return "", common.FlagErrorf("%s.range is required", path)
|
||||
}
|
||||
rangeStr, ok := rangeRaw.(string)
|
||||
if !ok || strings.TrimSpace(rangeStr) == "" {
|
||||
return "", common.FlagErrorf("%s.range must be a non-empty string", path)
|
||||
}
|
||||
return strings.TrimSpace(rangeStr), nil
|
||||
}
|
||||
|
||||
func rejectUnexpectedWorkbookStyleFields(op map[string]interface{}, path string, allowed ...string) error {
|
||||
allow := map[string]struct{}{}
|
||||
for _, k := range allowed {
|
||||
allow[k] = struct{}{}
|
||||
}
|
||||
for k := range op {
|
||||
if _, ok := allow[k]; !ok {
|
||||
return common.FlagErrorf("%s.%s is not valid here", path, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeWorkbookResizeRange(rangeStr string) string {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if !strings.Contains(rangeStr, ":") {
|
||||
return rangeStr + ":" + rangeStr
|
||||
}
|
||||
return rangeStr
|
||||
}
|
||||
|
||||
func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string) (map[string]interface{}, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
cellStyle := map[string]interface{}{}
|
||||
for k, v := range in {
|
||||
switch k {
|
||||
case "cell_styles":
|
||||
return nil, common.FlagErrorf("%s.cell_styles is not supported inside cell_styles[]; put style fields directly on the item", path)
|
||||
case "border_styles":
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s.border_styles must be a JSON object", path)
|
||||
}
|
||||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["border_styles"] = m
|
||||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||||
return nil, common.FlagErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
|
||||
default:
|
||||
if !workbookCreateCellStyleField(k) {
|
||||
return nil, common.FlagErrorf("%s.%s is not a supported style field", path, k)
|
||||
}
|
||||
cellStyle[k] = v
|
||||
}
|
||||
}
|
||||
if len(cellStyle) > 0 {
|
||||
out["cell_styles"] = cellStyle
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkbookBorderStyles checks a border_styles object's internal shape
|
||||
// (per-side style/weight enums + color) at parse time. --styles is on
|
||||
// parseJSONFlagSkip so it bypasses the generic schema validator; this keeps
|
||||
// border errors caught in the CLI (mirroring +cells-set-style) rather than being
|
||||
// passed straight through to the backend.
|
||||
func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||||
for side, raw := range m {
|
||||
switch side {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
return common.FlagErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("%s.border_styles.%s must be a JSON object", path, side)
|
||||
}
|
||||
for k, v := range spec {
|
||||
switch k {
|
||||
case "style":
|
||||
if s, _ := v.(string); !workbookBorderStyleEnum(s) {
|
||||
return common.FlagErrorf("%s.border_styles.%s.style %q is invalid (want solid/dashed/dotted/double/none)", path, side, s)
|
||||
}
|
||||
case "weight":
|
||||
if w, _ := v.(string); w != "thin" && w != "medium" && w != "thick" {
|
||||
return common.FlagErrorf("%s.border_styles.%s.weight %q is invalid (want thin/medium/thick)", path, side, w)
|
||||
}
|
||||
case "color":
|
||||
if _, ok := v.(string); !ok {
|
||||
return common.FlagErrorf("%s.border_styles.%s.color must be a string", path, side)
|
||||
}
|
||||
default:
|
||||
return common.FlagErrorf("%s.border_styles.%s.%s is not valid (want style/weight/color)", path, side, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func workbookBorderStyleEnum(s string) bool {
|
||||
switch s {
|
||||
case "solid", "dashed", "dotted", "double", "none":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol, baseRow int) (rows, cols int) {
|
||||
if styles == nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if startCol < baseCol || startRow < baseRow {
|
||||
continue
|
||||
}
|
||||
if endCol-baseCol+1 > cols {
|
||||
cols = endCol - baseCol + 1
|
||||
}
|
||||
if endRow-baseRow+1 > rows {
|
||||
rows = endRow - baseRow + 1
|
||||
}
|
||||
}
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
for i, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
}
|
||||
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
|
||||
return common.FlagErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
|
||||
label, i, op.Range,
|
||||
columnIndexToLetter(baseCol), baseRow+1,
|
||||
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
|
||||
}
|
||||
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
|
||||
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
|
||||
mergeWorkbookCreateStyle(rows[r][c], op.Style)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {
|
||||
if dry == nil || styles == nil {
|
||||
return
|
||||
}
|
||||
for _, op := range workbookCreateVisualOps(styles) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, sheetID, sheetName, op)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
wireBody, _ := buildToolBody(toolName, input)
|
||||
dry.POST(toolInvokePath(token, ToolKindWrite)).
|
||||
Desc(fmt.Sprintf("apply %s %s", op.Kind, op.Range)).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
|
||||
func applyWorkbookCreateVisualOps(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, styles *workbookCreateStylePayload) error {
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
for _, op := range workbookCreateVisualOps(styles) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, sheetID, "", op)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, input); err != nil {
|
||||
return fmt.Errorf("%s %s failed: %w", op.Kind, op.Range, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreateStyleOp {
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
|
||||
for _, op := range styles.CellMerges {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||||
}
|
||||
for _, op := range styles.RowSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "row_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
for _, op := range styles.ColSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
type workbookCreateStyleOp struct {
|
||||
Kind string
|
||||
Range string
|
||||
MergeType string
|
||||
ResizeType string
|
||||
Size int
|
||||
}
|
||||
|
||||
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
|
||||
switch op.Kind {
|
||||
case "cell_merge":
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": op.Range,
|
||||
"operation": "merge",
|
||||
"merge_type": op.MergeType,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
return input, "merge_cells"
|
||||
case "row_size", "col_size":
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": op.Range,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
block := map[string]interface{}{"type": op.ResizeType}
|
||||
if op.ResizeType == "pixel" {
|
||||
block["value"] = op.Size
|
||||
}
|
||||
if op.Kind == "row_size" {
|
||||
input["resize_height"] = block
|
||||
} else {
|
||||
input["resize_width"] = block
|
||||
}
|
||||
return input, "resize_range"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
func workbookCreateStyleRangeBounds(rangeStr string) (startCol, startRow, endCol, endRow int, err error) {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
rangeStr = rangeStr[idx+1:]
|
||||
}
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
return 0, 0, 0, 0, fmt.Errorf("empty range")
|
||||
}
|
||||
parts := strings.SplitN(rangeStr, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
col, row, ok := splitCellRef(parts[0])
|
||||
if !ok {
|
||||
return 0, 0, 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||||
}
|
||||
return col, row, col, row, nil
|
||||
}
|
||||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||||
}
|
||||
if endRow < startRow || endCol < startCol {
|
||||
return 0, 0, 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||||
}
|
||||
return startCol, startRow, endCol, endRow, nil
|
||||
}
|
||||
|
||||
// mergeWorkbookCreateStyle merges one cell_styles op's style map into a cell.
|
||||
// cell_styles / border_styles are nested submaps: they are deep-merged one level
|
||||
// (field-wise, last write wins) so overlapping cell_styles ops accumulate fields
|
||||
// rather than the later op's submap wholesale-replacing the earlier one. A fresh
|
||||
// submap is allocated each merge so the op.Style shared across the range's cells
|
||||
// is never mutated.
|
||||
func mergeWorkbookCreateStyle(cell interface{}, style map[string]interface{}) {
|
||||
if len(style) == 0 {
|
||||
return
|
||||
}
|
||||
m, ok := cell.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for k, v := range style {
|
||||
if k == "cell_styles" || k == "border_styles" {
|
||||
if incoming, ok := v.(map[string]interface{}); ok {
|
||||
merged := map[string]interface{}{}
|
||||
if existing, ok := m[k].(map[string]interface{}); ok {
|
||||
for sk, sv := range existing {
|
||||
merged[sk] = sv
|
||||
}
|
||||
}
|
||||
for sk, sv := range incoming {
|
||||
merged[sk] = sv
|
||||
}
|
||||
m[k] = merged
|
||||
continue
|
||||
}
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +workbook-export (legacy OAPI, cli_status: cli-only) ────────────
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -305,7 +306,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
@@ -317,12 +318,11 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Run("with values → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
@@ -334,7 +334,88 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 2 {
|
||||
t.Fatalf("cells rows = %#v, want 2", input["cells"])
|
||||
}
|
||||
headerRow, _ := cells[0].([]interface{})
|
||||
firstHeader, _ := headerRow[0].(map[string]interface{})
|
||||
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
|
||||
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
|
||||
t.Errorf("first header style = %#v, want bold + background", firstStyle)
|
||||
}
|
||||
secondHeader, _ := headerRow[1].(map[string]interface{})
|
||||
if secondHeader["border_styles"] == nil {
|
||||
t.Errorf("second header missing border_styles: %#v", secondHeader)
|
||||
}
|
||||
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
|
||||
if secondStyle["number_format"] != "0" {
|
||||
t.Errorf("second header number_format = %#v, want 0", secondStyle)
|
||||
}
|
||||
dataRow, _ := cells[1].([]interface{})
|
||||
firstData, _ := dataRow[0].(map[string]interface{})
|
||||
if _, ok := firstData["cell_styles"]; ok {
|
||||
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
|
||||
}
|
||||
secondData, _ := dataRow[1].(map[string]interface{})
|
||||
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
|
||||
if secondDataStyle["font_color"] != "#0f7b0f" {
|
||||
t.Errorf("second data style = %#v, want font color", secondDataStyle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a","b"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
|
||||
b1, _ := row0[1].(map[string]interface{})
|
||||
b1s, _ := b1["cell_styles"].(map[string]interface{})
|
||||
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
|
||||
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
|
||||
}
|
||||
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
|
||||
a1, _ := row0[0].(map[string]interface{})
|
||||
a1s, _ := a1["cell_styles"].(map[string]interface{})
|
||||
if a1s["font_color"] != nil {
|
||||
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -347,8 +428,18 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
|
||||
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
|
||||
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
|
||||
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
|
||||
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -42,7 +42,7 @@ metadata:
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put`(列显式声明 `type` + `format`,类型保真;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells) | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--headers` / `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — |
|
||||
| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` |
|
||||
| 查找并替换 | `+cells-replace` | — |
|
||||
@@ -107,7 +107,7 @@ metadata:
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--headers` / `--values` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
|
||||
@@ -138,10 +138,9 @@ _系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--title` | string | required | 新 spreadsheet 标题 |
|
||||
| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 |
|
||||
| `--headers` | string + File + Stdin(简单 JSON) | optional | 表头行 JSON 数组:`["列A","列B"]` |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | 初始数据 JSON 二维数组:`[["alice",95]]` |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]};type 为 string/number/date/bool。与 --headers/--values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
|
||||
| `--header-style` | bool | optional | 把 typed 表头行加粗(仅 --sheets 时生效,默认 true) |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]};type 为 string/number/date/bool。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。 |
|
||||
|
||||
### `+workbook-export`
|
||||
|
||||
@@ -178,6 +177,16 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
- `columns` (array<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
|
||||
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
|
||||
|
||||
### `+workbook-create` `--styles`
|
||||
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。
|
||||
@@ -188,12 +197,13 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
### `+workbook-create`
|
||||
|
||||
新建电子表格,可选预填数据。两种数据入口**互斥**,按需二选一:
|
||||
新建电子表格,可选预填数据。两种数据入口(untyped `--values` / typed `--sheets`)**互斥**,按需二选一——两者都走同一条分批 `set_cell_range` 写入:
|
||||
|
||||
```bash
|
||||
# 1) untyped:--headers + --values(纯值;类型由飞书自动识别,日期会落成文本)
|
||||
# 1) untyped:--values(一个二维数组,表头并入第一行;值原样写、类型由飞书自动识别,
|
||||
# 日期会落成文本,配 --styles 控制格式)
|
||||
lark-cli sheets +workbook-create --title "销售" \
|
||||
--headers '["门店","销售额"]' --values '[["北京",259874]]'
|
||||
--values '[["门店","销售额"],["北京",259874]]'
|
||||
|
||||
# 2) typed:--sheets(一步建表 + 类型保真)。date 列落成真日期(可排序/透视)、
|
||||
# number 不丢精度、string 列保前导零(如订单号 00123);多子表一次建。
|
||||
@@ -209,6 +219,69 @@ lark-cli sheets +workbook-create --title "交易" --sheets '{
|
||||
|
||||
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令,是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip)。
|
||||
|
||||
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
|
||||
|
||||
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `number_format` 等)和可选 `border_styles`;这些样式会合并进同一次内容 `set_cell_range`。
|
||||
- `cell_merges`:用 A1 单元格 `range` 设置合并,`merge_type` 默认为 `all`,可选 `rows` / `columns`。
|
||||
- `row_sizes`:用行范围(如 `1:3`)设置行高,`type` 为 `pixel` / `standard` / `auto`;`pixel` 需要 `size`。
|
||||
- `col_sizes`:用列范围(如 `A:C`)设置列宽,`type` 为 `pixel` / `standard`;`pixel` 需要 `size`。
|
||||
|
||||
同一单元格命中多个 `cell_styles` 项时,后面的操作继续合并覆盖已传字段。`cell_merges` / `row_sizes` / `col_sizes` 在内容写入后顺序执行。
|
||||
|
||||
```bash
|
||||
# 3) untyped:仍用 {"styles":[...]},只有一个子表样式项(name 忽略);range 覆盖 --values 初始区域
|
||||
lark-cli sheets +workbook-create --title "销售" \
|
||||
--values '[["门店","销售额"],["北京",259874],["上海",198320]]' \
|
||||
--styles '{
|
||||
"styles":[
|
||||
{"name":"Sheet1","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5"},
|
||||
{"range":"B2:B3","number_format":"#,##0"}
|
||||
]}
|
||||
]
|
||||
}'
|
||||
|
||||
# 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name
|
||||
lark-cli sheets +workbook-create --title "交易" --sheets '{
|
||||
"sheets":[
|
||||
{"name":"明细","columns":[
|
||||
{"name":"日期","type":"date"},
|
||||
{"name":"金额","type":"number","format":"#,##0.00"}
|
||||
],"rows":[["2024-01-15",1234.5]]}
|
||||
]}' --styles '{
|
||||
"styles":[
|
||||
{"name":"明细",
|
||||
"cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5",
|
||||
"border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},
|
||||
{"range":"A2:A2","number_format":"yyyy-mm-dd"},
|
||||
{"range":"B2:B2","number_format":"#,##0.00","font_color":"#0f7b0f"}
|
||||
],
|
||||
"cell_merges":[{"range":"A1:B1"}],
|
||||
"col_sizes":[{"range":"A:B","type":"pixel","size":120}],
|
||||
"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}
|
||||
]
|
||||
}'
|
||||
|
||||
# 5) typed 多子表:styles 数组和 sheets 数组长度、顺序、name 都必须一致
|
||||
lark-cli sheets +workbook-create --title "经营看板" --sheets '{
|
||||
"sheets":[
|
||||
{"name":"收入","columns":[{"name":"月份","type":"string"},{"name":"收入","type":"number","format":"#,##0"}],"rows":[["2026-05",1200000]]},
|
||||
{"name":"成本","columns":[{"name":"月份","type":"string"},{"name":"成本","type":"number","format":"#,##0"}],"rows":[["2026-05",730000]]}
|
||||
]}' --styles '{
|
||||
"styles":[
|
||||
{"name":"收入","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f0f7ff"},
|
||||
{"range":"B2:B2","font_color":"#0f7b0f"}
|
||||
]},
|
||||
{"name":"成本","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#fff7ed"},
|
||||
{"range":"B2:B2","font_color":"#b42318"}
|
||||
]}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
> ⚠️ **`+workbook-create` 是把内存里的数据写成新表;要把已有的本地 Excel/CSV 文件原样导入成新表,用 `+workbook-import`**(见下),不要先在本地读出文件再 `+workbook-create` 重灌。
|
||||
|
||||
### `+workbook-import`
|
||||
@@ -283,6 +356,6 @@ lark-cli sheets +sheet-hide-gridline --url "..." --sheet-id "$SID"
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`;`+workbook-create` 的 `--sheets` 与 `--headers`/`--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload(其余约束同 `+table-put`)。
|
||||
- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`;`+workbook-create` 的 `--sheets` 与 `--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload(其余约束同 `+table-put`)。
|
||||
- `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
|
||||
- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。
|
||||
|
||||
@@ -318,7 +318,6 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | required | typed 表格协议 JSON:顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]};type 为 string/number/date/bool |
|
||||
| `--header-style` | bool | optional | 把列名表头行加粗(默认 true) |
|
||||
|
||||
## Schemas
|
||||
|
||||
|
||||
Reference in New Issue
Block a user