From 27d185c91c301f64ed91640936330b4d70e073c8 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 12 Jun 2026 12:02:32 +0800 Subject: [PATCH] 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) --- shortcuts/sheets/data/flag-defs.json | 34 +- shortcuts/sheets/data/flag-schemas.json | 293 +++++- shortcuts/sheets/execute_paths_test.go | 26 +- shortcuts/sheets/flag_defs_gen.go | 8 +- shortcuts/sheets/flag_schema_validate.go | 1 + shortcuts/sheets/lark_sheet_table_io.go | 54 +- shortcuts/sheets/lark_sheet_table_io_test.go | 95 +- shortcuts/sheets/lark_sheet_workbook.go | 920 +++++++++++++++--- shortcuts/sheets/lark_sheet_workbook_test.go | 103 +- skills/lark-sheets/SKILL.md | 4 +- .../references/lark-sheets-workbook.md | 89 +- .../references/lark-sheets-write-cells.md | 1 - 12 files changed, 1406 insertions(+), 222 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 6df7df78a..a6e5fcf19 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -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", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 53b35eb4a..c5f74def3 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -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" } } } diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 8cd24bb25..cbb29a003 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -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"]) diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 535bca8ef..2442d0ab1 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -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"}, }, }, diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index fb3299f2c..adb59f445 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -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 diff --git a/shortcuts/sheets/lark_sheet_table_io.go b/shortcuts/sheets/lark_sheet_table_io.go index 0bc04663e..72c09a018 100644 --- a/shortcuts/sheets/lark_sheet_table_io.go +++ b/shortcuts/sheets/lark_sheet_table_io.go @@ -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)) diff --git a/shortcuts/sheets/lark_sheet_table_io_test.go b/shortcuts/sheets/lark_sheet_table_io_test.go index 820e6eb3f..30a211876 100644 --- a/shortcuts/sheets/lark_sheet_table_io_test.go +++ b/shortcuts/sheets/lark_sheet_table_io_test.go @@ -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`) + } +} diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 509f93021..9e6498dfc 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -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": "", - "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//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"] = "" - fill["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": "", + "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//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, "", "", 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) ──────────── diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index d5c081cac..aff112476 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -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) { diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 553335321..7c4103ec7 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -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 ""`,确认返回的 `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`,否则照样报上面的错。 diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index f8201cf96..c7ac73351 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -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) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string } - `rows` (array>) — 数据行;每行是一个数组,长度必须等于 columns 数 +### `+workbook-create` `--styles` + + +**数组项**(类型 object): +- `cell_merges` (array?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string } +- `cell_styles` (array?) — 单元格样式操作数组;每项用 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?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum } +- `name` (string) — 子表名 +- `row_sizes` (array?) — 行高操作数组;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 输出里生成为 `` 占位符,不实际解析为 sheet-id。 - `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 36859ed29..349c6d868 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -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