From 8906e87fb18ab6547e55b2befbfa47f9943af878 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 5 Jun 2026 20:03:33 +0800 Subject: [PATCH] feat(sheets): implement table-put/table-get and sync skill specs - Add lark_sheet_table_io.go with +table-put / +table-get and tests - Refactor read-data; extend workbook; register new shortcuts - Sync generated flag defs/schemas (go:embed) from sheet-skill-spec - Sync skill references (write-cells numeric-column guidance, plus read-data / workbook / chart updates) --- shortcuts/sheets/data/flag-defs.json | 118 +- shortcuts/sheets/data/flag-schemas.json | 196 ++- shortcuts/sheets/flag_defs_gen.go | 25 +- shortcuts/sheets/flag_schemas_gen.go | 2 + shortcuts/sheets/lark_sheet_read_data.go | 227 +--- shortcuts/sheets/lark_sheet_read_data_test.go | 124 -- shortcuts/sheets/lark_sheet_table_io.go | 1148 +++++++++++++++++ shortcuts/sheets/lark_sheet_table_io_test.go | 991 ++++++++++++++ shortcuts/sheets/lark_sheet_workbook.go | 64 +- shortcuts/sheets/shortcuts.go | 2 + .../references/lark-sheets-chart.md | 4 +- .../references/lark-sheets-read-data.md | 53 +- .../references/lark-sheets-workbook.md | 44 +- .../references/lark-sheets-write-cells.md | 104 +- 14 files changed, 2699 insertions(+), 403 deletions(-) create mode 100644 shortcuts/sheets/lark_sheet_table_io.go create mode 100644 shortcuts/sheets/lark_sheet_table_io_test.go diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index bfd13e441..07e7046d4 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -532,6 +532,25 @@ "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": [ + "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": "dry-run", "kind": "system", @@ -1292,19 +1311,65 @@ "desc": "Skip hidden rows and columns; default `false`" }, { - "name": "rows-json", + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request path and parameters without executing" + } + ] + }, + "+table-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Read only this sheet (by id); omit to read all sheets" + }, + { + "name": "sheet-name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Read only this sheet (by name); omit to read all sheets" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "A1 range to read; omit to read each sheet current region" + }, + { + "name": "no-header", "kind": "own", "type": "bool", "required": "optional", - "desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", - "default": "false" + "desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)" }, { "name": "dry-run", "kind": "system", "type": "bool", "required": "optional", - "desc": "Print the request path and parameters without executing" + "desc": "" } ] }, @@ -1960,6 +2025,51 @@ } ] }, + "+table-put": { + "risk": "write", + "flags": [ + { + "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": [ + "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", + "desc": "" + } + ] + }, "+cells-clear": { "risk": "high-risk-write", "flags": [ diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index c7aad1edf..53b35eb4a 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -1787,11 +1787,7 @@ "data" ] } - }, - "required": [ - "position", - "size" - ] + } } }, "+chart-update": { @@ -2826,11 +2822,7 @@ "data" ] } - }, - "required": [ - "position", - "size" - ] + } } }, "+cond-format-create": { @@ -6249,6 +6241,190 @@ } } } + }, + "+table-put": { + "sheets": { + "type": "array", + "minItems": 1, + "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。", + "items": { + "type": "object", + "required": [ + "name", + "columns", + "rows" + ], + "properties": { + "name": { + "type": "string", + "description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。" + }, + "start_cell": { + "type": "string", + "default": "A1", + "description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。" + }, + "mode": { + "type": "string", + "enum": [ + "overwrite", + "append" + ], + "default": "overwrite", + "description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。" + }, + "header": { + "type": "boolean", + "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" + }, + "allow_overwrite": { + "type": "boolean", + "default": true, + "description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。" + }, + "columns": { + "type": "array", + "minItems": 1, + "description": "列定义,顺序与 rows 中每行的取值一一对应。", + "items": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "type": "string", + "description": "列名(写入表头行的文本)。" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "date", + "bool" + ], + "description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。" + }, + "format": { + "type": "string", + "description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。" + } + } + } + }, + "rows": { + "type": "array", + "description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。", + "items": { + "type": "array", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ], + "description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。" + } + } + } + } + } + } + }, + "+workbook-create": { + "sheets": { + "type": "array", + "minItems": 1, + "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。", + "items": { + "type": "object", + "required": [ + "name", + "columns", + "rows" + ], + "properties": { + "name": { + "type": "string", + "description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。" + }, + "start_cell": { + "type": "string", + "default": "A1", + "description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。" + }, + "mode": { + "type": "string", + "enum": [ + "overwrite", + "append" + ], + "default": "overwrite", + "description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。" + }, + "header": { + "type": "boolean", + "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" + }, + "allow_overwrite": { + "type": "boolean", + "default": true, + "description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。" + }, + "columns": { + "type": "array", + "minItems": 1, + "description": "列定义,顺序与 rows 中每行的取值一一对应。", + "items": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "type": "string", + "description": "列名(写入表头行的文本)。" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "date", + "bool" + ], + "description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。" + }, + "format": { + "type": "string", + "description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。" + } + } + } + }, + "rows": { + "type": "array", + "description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。", + "items": { + "type": "array", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ], + "description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。" + } + } + } + } + } + } } } } diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 3d1689b8e..2e5c12a99 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -308,7 +308,6 @@ var flagDefs = map[string]commandDef{ {Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true}, {Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"}, {Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"}, - {Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"}, }, }, @@ -915,6 +914,28 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+table-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"}, + {Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"}, + {Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+table-put": { + Risk: "write", + Flags: []flagDef{ + {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"}, + }, + }, "+workbook-create": { Risk: "write", Flags: []flagDef{ @@ -922,6 +943,8 @@ var flagDefs = map[string]commandDef{ {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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, diff --git a/shortcuts/sheets/flag_schemas_gen.go b/shortcuts/sheets/flag_schemas_gen.go index 6dedf8e94..95a7f4408 100644 --- a/shortcuts/sheets/flag_schemas_gen.go +++ b/shortcuts/sheets/flag_schemas_gen.go @@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{ "+range-sort": {}, "+sparkline-create": {}, "+sparkline-update": {}, + "+table-put": {}, + "+workbook-create": {}, } diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 40044c94a..4eeca8b79 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -5,8 +5,6 @@ package sheets import ( "context" - "encoding/csv" - "regexp" "strconv" "strings" @@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{ if err != nil { return err } - switch { - case runtime.Bool("rows-json"): - // --rows-json reshapes the CSV response into structured rows - // ({row_number, values:{col→cell}}); see assembleRowsJSON. - out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range"))) - case !runtime.Bool("include-row-prefix"): + if !runtime.Bool("include-row-prefix") { out = stripRowPrefixFromCsvOutput(out) } runtime.Out(out, nil) @@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} { return m } -// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that -// the tool prepends to the first physical line of each logical CSV record. -var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`) - -// assembleRowsJSON reshapes the tool's annotated_csv string into structured -// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand: -// -// { -// "range": "A1:K3380", -// "current_region": "...", // passthrough, if the tool returned it -// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}}, -// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...] -// } -// -// Every logical row is emitted, including the first — no row is assumed to be a -// header, since sheet data is not always tabular. Each cell is keyed by its -// column letter (from the tool's col_indices when present, else derived from the -// requested range's start column). On any parsing trouble it returns the -// original output unchanged. -func assembleRowsJSON(out interface{}, requestedRange string) interface{} { - m, ok := out.(map[string]interface{}) - if !ok { - return out - } - csvStr, ok := m["annotated_csv"].(string) - if !ok { - return out - } - - // Group physical lines into logical records by [row=N] boundaries; lines - // without a prefix are embedded-newline continuations of the current record. - type logicalRow struct { - num int - text string - } - var groups []logicalRow - for _, line := range strings.Split(csvStr, "\n") { - if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil { - n, _ := strconv.Atoi(mm[1]) - groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]}) - } else if len(groups) > 0 { - groups[len(groups)-1].text += "\n" + line - } - } - if len(groups) == 0 { - return out - } - - // Parse every logical row; widest row sets the column count. No row is - // singled out as a header — that would assume the data is tabular, which it - // often is not. The model reads row 1 like any other row and decides for - // itself whether it is a header. - parsed := make([][]string, len(groups)) - maxCols := 0 - for i, g := range groups { - parsed[i] = parseCSVRecord(g.text) - if len(parsed[i]) > maxCols { - maxCols = len(parsed[i]) - } - } - if maxCols == 0 { - return out - } - - // Column letters key each cell. Prefer the tool's col_indices (authoritative, - // length == col_count); otherwise derive from the requested range's start col. - letters := coerceStringSlice(m["col_indices"]) - if len(letters) < maxCols { - start := csvStartColIndex(requestedRange) - letters = make([]string, maxCols) - for j := 0; j < maxCols; j++ { - letters[j] = csvColLetter(start + j) - } - } - - rows := make([]map[string]interface{}, 0, len(groups)) - for i := range groups { - fields := parsed[i] - values := make(map[string]interface{}, len(letters)) - for j := range letters { - v := "" - if j < len(fields) { - v = fields[j] - } - values[letters[j]] = v - } - rows = append(rows, map[string]interface{}{ - "row_number": groups[i].num, - "values": values, - }) - } - - result := map[string]interface{}{} - for k, v := range m { - result[k] = v - } - result["range"] = requestedRange - result["rows"] = rows - - // Surface the backend's "数据没读全" signal structurally instead of leaving it - // buried in warning_message prose. The tool flags it when current_region (the - // true data extent) reaches past actual_range (what was actually read) — the - // single most important anti-under-read hint. Mirror that same comparison - // (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the - // model gets the real data range as a first-class field, never having to - // parse it out of prose. - if cr, _ := m["current_region"].(string); cr != "" { - ar, _ := m["actual_range"].(string) - regionEnd := a1EndRow(cr) - readEnd := a1EndRow(ar) - if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd { - result["data_not_fully_read"] = map[string]interface{}{ - "read_through_row": readEnd, - "data_extends_through_row": regionEnd, - "unread_rows": regionEnd - readEnd, - "reread_range": cr, - } - } - } - - // Drop the fields whose information rows-json fully carries elsewhere: - // - annotated_csv / row_indices / col_indices → reconstructed into - // columns + rows (with integer row_number), losslessly. - // - warning_message → its two halves are both handled: the static - // "[row=N] / col_indices[j]" parse nag is moot once those fields exist, - // and the dynamic "数据没读全" half is now the structured - // data_not_fully_read field above. (Confirmed against the backend's - // get-range-as-csv.ts — warning_message has no other content.) - delete(result, "annotated_csv") - delete(result, "row_indices") - delete(result, "col_indices") - delete(result, "warning_message") - return result -} - // a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51, // "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present. func a1EndRow(rng string) int { @@ -377,89 +235,6 @@ func a1EndRow(rng string) int { return n } -// parseCSVRecord parses a single logical CSV record (which may span multiple -// physical lines via quoted embedded newlines) into its fields. An empty record -// yields no fields; a malformed record falls back to a naive comma split so a -// stray quote never drops a whole row. -func parseCSVRecord(text string) []string { - if strings.TrimSpace(text) == "" { - return nil - } - r := csv.NewReader(strings.NewReader(text)) - r.FieldsPerRecord = -1 - fields, err := r.Read() - if err != nil { - return strings.Split(text, ",") - } - return fields -} - -// coerceStringSlice returns v as []string when it is a homogeneous []interface{} -// of strings (the shape of the tool's col_indices), else nil. -func coerceStringSlice(v interface{}) []string { - arr, ok := v.([]interface{}) - if !ok { - return nil - } - out := make([]string, 0, len(arr)) - for _, e := range arr { - s, ok := e.(string) - if !ok { - return nil - } - out = append(out, s) - } - return out -} - -// csvStartColIndex returns the 0-based column index of a range's start column, -// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0. -func csvStartColIndex(rng string) int { - rng = strings.TrimSpace(rng) - if i := strings.LastIndex(rng, "!"); i >= 0 { - rng = rng[i+1:] - } - var letters strings.Builder - for _, c := range rng { - if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { - letters.WriteRune(c) - continue - } - break - } - if letters.Len() == 0 { - return 0 - } - return csvColToIndex(letters.String()) -} - -// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10, -// "AA"→26). Non-letter input → -1. -func csvColToIndex(s string) int { - n := 0 - for _, c := range strings.ToUpper(s) { - if c < 'A' || c > 'Z' { - break - } - n = n*26 + int(c-'A'+1) - } - return n - 1 -} - -// csvColLetter converts a 0-based column index back to its letter (0→"A", -// 25→"Z", 26→"AA"). Negative input → "". -func csvColLetter(idx int) string { - if idx < 0 { - return "" - } - var b []byte - for idx >= 0 { - b = append([]byte{byte('A' + idx%26)}, b...) - idx = idx/26 - 1 - } - return string(b) -} - // DropdownGet wraps get_cell_ranges scoped to data_validation: read the // dropdown configuration on a range. Aligned with its sibling +cells-get // — sheet selection is via --sheet-id / --sheet-name (XOR), and --range diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index 01e8001de..6408526cc 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { "value_render_option": "formatted_value", }, }, - { - // --rows-json is post-processing on +csv-get's response; it must - // NOT leak into the get_range_as_csv input. - name: "+csv-get --rows-json builds the same input (flag is post-process)", - sc: CsvGet, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"}, - toolName: "get_range_as_csv", - wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "range": "A1:C10", - "max_rows": float64(unboundedReadLimit), - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) { t.Errorf("other field corrupted: %v", out["other"]) } } - -// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row -// emitted (no header singled out), integer row_number, column-letter keyed -// values, embedded newlines inside quoted fields, and current_region passthrough. -func TestAssembleRowsJSON(t *testing.T) { - t.Parallel() - in := map[string]interface{}{ - "annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3", - "current_region": "A1:C3", - "col_indices": []interface{}{"A", "B", "C"}, - "row_indices": []interface{}{1, 2, 3}, - "warning_message": "①定位行号…②定位列字母…", - } - out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{}) - if !ok { - t.Fatalf("assembleRowsJSON did not return a map") - } - - // Fields whose info rows-json carries elsewhere are dropped (annotated_csv / - // indices → rows; warning_message → moot static nag + structured - // data_not_fully_read). Unrelated metadata like current_region is preserved. - if _, exists := out["annotated_csv"]; exists { - t.Errorf("annotated_csv should be dropped") - } - if _, exists := out["col_indices"]; exists { - t.Errorf("col_indices should be dropped") - } - if _, exists := out["warning_message"]; exists { - t.Errorf("warning_message should be dropped in rows-json mode") - } - if _, exists := out["columns"]; exists { - t.Errorf("columns field should not exist (no header assumption)") - } - if out["current_region"] != "A1:C3" { - t.Errorf("current_region passthrough lost: %v", out["current_region"]) - } - - rows, _ := out["rows"].([]map[string]interface{}) - if len(rows) != 3 { - t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows) - } - // Row 1 is emitted as a normal row, not consumed as a header. - if rows[0]["row_number"].(int) != 1 { - t.Errorf("first row_number = %v, want 1", rows[0]["row_number"]) - } - if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" { - t.Errorf("row 1 values wrong: %+v", v) - } - // Row 2 keeps its embedded newline inside a single cell. - v1 := rows[1]["values"].(map[string]interface{}) - if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" { - t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1]) - } -} - -// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the -// range start when the tool omits col_indices (e.g. a C-anchored read). -func TestAssembleRowsJSON_DerivedLetters(t *testing.T) { - t.Parallel() - in := map[string]interface{}{ - "annotated_csv": "[row=5] h1,h2\n[row=6] a,b", - } - out := assembleRowsJSON(in, "C5:D6").(map[string]interface{}) - rows := out["rows"].([]map[string]interface{}) - if len(rows) != 2 { - t.Fatalf("want 2 rows, got %d", len(rows)) - } - if rows[0]["row_number"].(int) != 5 { - t.Errorf("first row_number = %v, want 5", rows[0]["row_number"]) - } - if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" { - t.Errorf("derived-letter values wrong: %+v", v) - } - if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" { - t.Errorf("row 6 values wrong: %+v", v) - } -} - -// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint: -// when current_region extends past actual_range, rows-json surfaces the true data -// range as a first-class field (mirroring the backend's prose warning). -func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) { - t.Parallel() - // Read only A1:D2, but the data region reaches D4 → 2 rows unread. - in := map[string]interface{}{ - "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", - "actual_range": "A1:D2", - "current_region": "A1:D4", - } - out := assembleRowsJSON(in, "A1:D2").(map[string]interface{}) - hint, ok := out["data_not_fully_read"].(map[string]interface{}) - if !ok { - t.Fatalf("data_not_fully_read missing; out=%+v", out) - } - if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 || - hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" { - t.Errorf("data_not_fully_read wrong: %+v", hint) - } - - // Fully-read case: no hint emitted. - in2 := map[string]interface{}{ - "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", - "actual_range": "A1:D2", - "current_region": "A1:D2", - } - out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{}) - if _, exists := out2["data_not_fully_read"]; exists { - t.Errorf("data_not_fully_read should be absent when fully read") - } -} diff --git a/shortcuts/sheets/lark_sheet_table_io.go b/shortcuts/sheets/lark_sheet_table_io.go new file mode 100644 index 000000000..18436a392 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_table_io.go @@ -0,0 +1,1148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── +table-put (cli-only, typed DataFrame-style import) ─────────── +// +// Imports a typed, column-described table into Lark Sheets, type-faithfully: +// numbers stay numbers, dates land as real dates (serial + date number_format), +// not look-alike text. The wire protocol is deliberately a "table with column +// types" — pandas DataFrames are its most common producer, but the CLI never +// has to know about pandas. +// +// Writes into an existing spreadsheet (addressed by --url / --spreadsheet-token); +// to create a fresh workbook first, use +workbook-create, then point +table-put +// at the returned token. Multiple DataFrames → multiple sheets in one call: the +// top-level `sheets` array carries one entry per sub-sheet, each matched to an +// existing sub-sheet by name (created when absent). +// +// Date faithfulness was verified empirically (see isoDateToSerial): the only +// way to get a *real* date (ISNUMBER=TRUE, sortable / pivotable) is to write +// the Excel serial number AND set a date number_format. A date *string*, even +// with a date format, stays text — so date columns always go through the +// serial conversion below. + +// TablePut is the typed table-put shortcut. It writes into an existing +// spreadsheet, composing get_workbook_structure / modify_workbook_structure / +// set_cell_range — no new backend tool, and no workbook creation (use +// +workbook-create for that, consistent with every other write shortcut). +var TablePut = common.Shortcut{ + Service: "sheets", + Command: "+table-put", + Description: "Write a typed table (columns with types + rows) into an existing spreadsheet; numbers and dates stay type-faithful.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+table-put"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, err := parseTablePutPayload(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return tablePutDryRun(runtime) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + payload, err := parseTablePutPayload(runtime) + if err != nil { + return err + } + return tablePutWrite(ctx, runtime, token, payload, runtime.Bool("header-style")) + }, + 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.", + "Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).", + }, +} + +// ─── protocol ───────────────────────────────────────────────────────── + +type tablePayload struct { + Sheets []tableSheetSpec `json:"sheets"` +} + +type tableSheetSpec struct { + Name string `json:"name"` + StartCell string `json:"start_cell"` + // Mode controls write placement: "overwrite" (default) writes a header+data + // block from start_cell; "append" writes data below the sheet's existing + // data (start_cell's row is ignored, its column is honored). + Mode string `json:"mode"` + // Header is whether to write a header row of column names. nil defaults by + // mode: true for overwrite, false for append (so appended rows don't repeat + // the header). Set explicitly to override. + Header *bool `json:"header"` + // AllowOverwrite, when explicitly false, makes the write fail if it would + // land on a non-empty cell. nil defaults to true (overwrite). + AllowOverwrite *bool `json:"allow_overwrite"` + Columns []tableColumnSpec `json:"columns"` + Rows [][]interface{} `json:"rows"` +} + +type tableColumnSpec struct { + Name string `json:"name"` + Type string `json:"type"` + Format string `json:"format"` +} + +// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a +// validated payload. UseNumber keeps numeric cells as json.Number so large +// integers (order IDs, etc.) survive without precision loss or scientific +// notation. Network-free: safe from Validate and DryRun. +func parseTablePutPayload(runtime flagView) (*tablePayload, error) { + raw := strings.TrimSpace(runtime.Str("sheets")) + if raw == "" { + return nil, common.FlagErrorf("--sheets is required") + } + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var p tablePayload + if err := dec.Decode(&p); err != nil { + return nil, common.FlagErrorf("--sheets: invalid JSON: %v", err) + } + if err := p.validate(); err != nil { + return nil, err + } + return &p, nil +} + +func (p *tablePayload) validate() error { + if len(p.Sheets) == 0 { + return common.FlagErrorf("--sheets: must contain at least one sheet") + } + seen := make(map[string]bool, len(p.Sheets)) + for i := range p.Sheets { + s := &p.Sheets[i] + if strings.TrimSpace(s.Name) == "" { + return common.FlagErrorf("--sheets[%d]: name is required", i) + } + if seen[s.Name] { + return common.FlagErrorf("--sheets[%d]: duplicate sheet name %q", i, s.Name) + } + seen[s.Name] = true + if len(s.Columns) == 0 { + return common.FlagErrorf("--sheets[%d] %q: columns must be non-empty", i, s.Name) + } + for j := range s.Columns { + c := &s.Columns[j] + if strings.TrimSpace(c.Name) == "" { + return common.FlagErrorf("--sheets[%d] %q: columns[%d].name is required", i, s.Name, j) + } + if !validColumnType(c.Type) { + return common.FlagErrorf("--sheets[%d] %q: columns[%d] %q has invalid type %q (want string/number/date/bool)", + i, s.Name, j, c.Name, c.Type) + } + } + for r := range s.Rows { + if len(s.Rows[r]) != len(s.Columns) { + return common.FlagErrorf("--sheets[%d] %q: row %d has %d cells, want %d (column count)", + i, s.Name, r, len(s.Rows[r]), len(s.Columns)) + } + // Validate each cell's value against its column type up front (pure, + // network-free): a bad date/number/bool is caught here — before any + // workbook is created — instead of failing mid-write and leaving a + // stray empty spreadsheet behind. + for c := range s.Columns { + if _, err := buildTypedCell(&s.Columns[c], s.Rows[r][c]); err != nil { + return common.FlagErrorf("--sheets[%d] %q: row %d column %q: %v", i, s.Name, r, s.Columns[c].Name, err) + } + } + } + if sc := strings.TrimSpace(s.StartCell); sc != "" { + if _, _, ok := splitCellRef(sc); !ok { + return common.FlagErrorf("--sheets[%d] %q: start_cell %q must be a single cell ref (e.g. A1)", i, s.Name, sc) + } + } + switch s.Mode { + case "", "overwrite", "append": + default: + return common.FlagErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode) + } + } + return nil +} + +func validColumnType(t string) bool { + switch t { + case "string", "number", "date", "bool": + return true + } + return false +} + +// ─── type mapping ───────────────────────────────────────────────────── + +// headerOn reports whether a header row of column names should be written. A +// nil Header defaults by mode: overwrite writes it; append omits it so the +// appended rows don't repeat the header below an existing one. +func headerOn(s *tableSheetSpec) bool { + if s.Header != nil { + return *s.Header + } + return s.Mode != "append" +} + +// 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) { + 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 + } + matrix = append(matrix, header) + } + + for r := range s.Rows { + row := make([]interface{}, ncols) + for c := range s.Columns { + cell, err := buildTypedCell(&s.Columns[c], s.Rows[r][c]) + if err != nil { + return nil, common.FlagErrorf("sheet %q row %d column %q: %v", s.Name, r, s.Columns[c].Name, err) + } + row[c] = cell + } + matrix = append(matrix, row) + } + return matrix, nil +} + +// 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. +func buildTypedCell(col *tableColumnSpec, raw interface{}) (map[string]interface{}, error) { + cell := map[string]interface{}{} + nf := strings.TrimSpace(col.Format) + if nf == "" { + switch col.Type { + case "date": + nf = "yyyy-mm-dd" + case "string": + // Text format keeps digit-like strings (IDs / postcodes / phone numbers) + // as text, and lets +table-get infer the column back as string instead + // of guessing number from a numeric-looking value. + nf = "@" + } + } + if nf != "" { + cell["cell_styles"] = map[string]interface{}{"number_format": nf} + } + if raw == nil { + return cell, nil + } + switch col.Type { + case "string": + cell["value"] = stringifyCellValue(raw) + case "number": + n, ok := raw.(json.Number) + if !ok { + return nil, fmt.Errorf("number expects a numeric value, got %s", describeJSONType(raw)) + } + cell["value"] = n + case "bool": + b, ok := raw.(bool) + if !ok { + return nil, fmt.Errorf("bool expects true/false, got %s", describeJSONType(raw)) + } + cell["value"] = b + case "date": + str, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("date expects an ISO yyyy-mm-dd string, got %s", describeJSONType(raw)) + } + serial, err := isoDateToSerial(str) + if err != nil { + return nil, err + } + cell["value"] = serial + default: + return nil, fmt.Errorf("unsupported type %q", col.Type) + } + return cell, nil +} + +// stringifyCellValue renders any JSON scalar as the literal text a string +// column should hold. json.Number keeps its exact digits (no scientific +// notation), so IDs / postcodes survive as written. +func stringifyCellValue(raw interface{}) string { + switch v := raw.(type) { + case string: + return v + case json.Number: + return v.String() + case bool: + if v { + return "TRUE" + } + return "FALSE" + default: + return fmt.Sprintf("%v", v) + } +} + +func describeJSONType(raw interface{}) string { + switch raw.(type) { + case string: + return "a string" + case json.Number: + return "a number" + case bool: + return "a boolean" + case []interface{}: + return "an array" + case map[string]interface{}: + return "an object" + default: + return fmt.Sprintf("%T", raw) + } +} + +// excelEpoch is the Excel / Lark Sheets serial-date origin (1899-12-30 = 0). +// Verified empirically: writing serial 45306 renders as 2024-01-15 in Lark +// Sheets, matching Excel's 1900 date system exactly. +var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) + +// isoDateToSerial converts an ISO yyyy-mm-dd string to its Excel serial day +// number. The result is written as a numeric cell value with a date +// number_format, which is the only combination that yields a real (sortable, +// pivotable, ISNUMBER=TRUE) date in Lark Sheets. +func isoDateToSerial(s string) (int, error) { + t, err := time.Parse("2006-01-02", strings.TrimSpace(s)) + if err != nil { + return 0, fmt.Errorf("date %q must be ISO yyyy-mm-dd: %v", s, err) + } + return int(math.Round(t.Sub(excelEpoch).Hours() / 24)), nil +} + +// ─── range helpers ──────────────────────────────────────────────────── + +// tablePutMaxCellsPerWrite caps a single set_cell_range write. Larger +// sheets are split into row batches so one oversized request can't exceed the +// tool's cell ceiling. Matches +cells-set's default --max-cells. +const tablePutMaxCellsPerWrite = 50000 + +// sheetAnchor returns the resolved start cell (default A1) and its 0-based +// column/row. Caller has already validated start_cell, so the parse can't fail +// in practice; the ok guard is defensive. +func sheetAnchor(s *tableSheetSpec) (anchor string, col0, row0 int, err error) { + anchor = strings.TrimSpace(s.StartCell) + if anchor == "" { + anchor = "A1" + } + c, r, ok := splitCellRef(anchor) + if !ok { + return "", 0, 0, common.FlagErrorf("start_cell %q must be a single cell ref (e.g. A1)", anchor) + } + return anchor, c, r, nil +} + +// tablePutFullRange is the A1 rectangle the whole matrix (header + data) +// occupies, for reporting in the result / dry-run. +func tablePutFullRange(s *tableSheetSpec, totalRows int) string { + _, col0, row0, err := sheetAnchor(s) + if err != nil { + return strings.TrimSpace(s.StartCell) + } + ncols := len(s.Columns) + return fmt.Sprintf("%s%d:%s%d", + columnIndexToLetter(col0), row0+1, + columnIndexToLetter(col0+ncols-1), row0+totalRows) +} + +// ─── write path ─────────────────────────────────────────────────────── + +// 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) { + _, col0, row0, err := sheetAnchor(s) + if err != nil { + return nil, err + } + ncols := len(s.Columns) + + // append mode starts below the sheet's existing data; start_cell's row is + // ignored (its column is still honored). overwrite mode anchors at row0. + baseRow := row0 + writeHeader := headerOn(s) + if s.Mode == "append" { + lastRow, err := lastDataRow(ctx, runtime, token, sheetID) + if err != nil { + return nil, fmt.Errorf("resolving last data row for append: %w", err) + } + if lastRow > 0 { + baseRow = lastRow // 0-based index of the row just below the 1-based last data row + } else if s.Header == nil { + // appending to an empty sheet with no explicit header choice: write the + // header so column names aren't lost (and a later +table-get doesn't + // consume the first data row as the header). + writeHeader = true + } + } + + matrix, err := buildSheetMatrix(s, headerStyle, writeHeader) + if err != nil { + return nil, err + } + + if len(matrix) == 0 { + // header:false with no data rows — nothing to write. + return map[string]interface{}{ + "name": s.Name, "sheet_id": sheetID, "range": "", + "data_rows": 0, "columns": ncols, "writes": 0, "mode": writeModeName(s), + }, nil + } + + startCol := columnIndexToLetter(col0) + endCol := columnIndexToLetter(col0 + ncols - 1) + allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite + + rowsPerBatch := tablePutMaxCellsPerWrite / ncols + if rowsPerBatch < 1 { + rowsPerBatch = 1 + } + + writes := 0 + for start := 0; start < len(matrix); start += rowsPerBatch { + end := start + rowsPerBatch + if end > len(matrix) { + end = len(matrix) + } + batchRange := fmt.Sprintf("%s%d:%s%d", startCol, baseRow+start+1, endCol, baseRow+end) + input := map[string]interface{}{ + "excel_id": token, + "sheet_id": sheetID, + "range": batchRange, + "cells": matrix[start:end], + } + if !allowOverwrite { + input["allow_overwrite"] = false + } + if _, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input); err != nil { + return nil, fmt.Errorf("writing rows %d-%d: %w", start+1, end, err) + } + writes++ + } + return map[string]interface{}{ + "name": s.Name, + "sheet_id": sheetID, + "range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)), + "data_rows": len(s.Rows), + "columns": ncols, + "writes": writes, + "mode": writeModeName(s), + }, nil +} + +// writeModeName normalizes the sheet's write mode to a non-empty label for +// result / dry-run reporting ("" defaults to "overwrite"). +func writeModeName(s *tableSheetSpec) string { + if s.Mode == "append" { + return "append" + } + return "overwrite" +} + +// lastDataRow returns the 1-based row number of the last row containing data in +// the sheet (0 when empty), so append mode can place new rows just below it. It +// reads current_region via get_range_as_csv — the backend's reported true data +// extent — anchored at A1. +func lastDataRow(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string) (int, error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", map[string]interface{}{ + "excel_id": token, + "sheet_id": sheetID, + "range": "A1", + "max_rows": unboundedReadLimit, + }) + if err != nil { + return 0, err + } + m, ok := out.(map[string]interface{}) + if !ok { + return 0, nil // empty sheet — no output + } + region, _ := m["current_region"].(string) + if region == "" { + region, _ = m["actual_range"].(string) + } + return a1EndRow(region), nil +} + +// writeTypedSheets writes a typed payload's sheets into a workbook and returns +// the per-sheet summaries. It deliberately does not emit output — the caller +// composes the envelope, because +table-put and +workbook-create report +// different top-level shapes (a bare token vs. the full spreadsheet metadata). +// Existing sub-sheets are matched by name in a single structure read; missing +// ones are created on demand. +// +// adoptSheetID, when non-empty, is the id of a freshly created workbook's +// default sub-sheet: the first payload sheet adopts it (the default sheet is +// renamed to that sheet's name and reused) so the new workbook isn't left with +// a stray empty "Sheet1" beside the typed sheets. +table-put passes "" (it +// writes into an existing workbook, with no default sheet to adopt); +// +workbook-create passes the default sheet's id. +// +// 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) { + byName, err := listSheetIDsByName(ctx, runtime, token) + if err != nil { + return nil, err + } + + // Adopt the default sheet as the first payload sheet (rename + reuse), so a + // just-created workbook doesn't keep its empty default sheet around. Skip if + // a sheet by that name already exists (it'll be matched normally below). + if adoptSheetID != "" && len(payload.Sheets) > 0 { + first := payload.Sheets[0].Name + if _, exists := byName[first]; !exists { + if err := renameSheet(ctx, runtime, token, adoptSheetID, first); err != nil { + return nil, fmt.Errorf("adopting the default sheet as %q failed: %w", first, err) + } + byName[first] = adoptSheetID + } + } + + written := make([]interface{}, 0, len(payload.Sheets)) + for i := range payload.Sheets { + s := &payload.Sheets[i] + sheetID, ok := byName[s.Name] + if !ok { + rows, cols := sheetCreateDims(s) + sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols) + if err != nil { + return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) + } + byName[s.Name] = sheetID + } + summary, err := writeSheetData(ctx, runtime, token, sheetID, s, headerStyle) + if err != nil { + return written, fmt.Errorf("writing sheet %q failed: %w", s.Name, err) + } + written = append(written, summary) + } + return written, nil +} + +// renameSheet renames a sub-sheet in place via modify_workbook_structure. Used +// to adopt a freshly created workbook's default sheet as the first typed sheet +// (see writeTypedSheets); mirrors +sheet-rename's tool input. +func renameSheet(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, newName string) error { + _, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "sheet_id": sheetID, + "new_name": newName, + }) + return err +} + +// 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, "") + if err != nil { + return tablePutPartial(token, nil, written, err.Error()) + } + runtime.Out(map[string]interface{}{ + "spreadsheet_token": token, + "sheets": written, + }, nil) + return nil +} + +// createSheet appends a new sub-sheet sized to hold the spec, then resolves its +// id. The backend's default sheet (20 cols × 200 rows) is too small for wide or +// long tables (e.g. a 37-column quarter matrix), so the create request sizes the +// sheet to the write range up front — otherwise the follow-up set_cell_range +// fails with "range … exceeds sheet bounds". modify_workbook_structure's create +// output shape isn't relied upon — the id is read back by name, which is robust +// across tool-response variations. +func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, name string, rows, cols int) (string, error) { + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "sheet_name": name, + } + if rows > 0 { + input["rows"] = rows + } + if cols > 0 { + input["columns"] = cols + } + if _, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input); err != nil { + return "", err + } + id, _, err := lookupSheetIndex(ctx, runtime, token, "", name) + if err != nil { + return "", fmt.Errorf("sheet %q created but resolving its id failed: %w", name, err) + } + return id, nil +} + +// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the +// follow-up set_cell_range can't exceed sheet bounds. It accounts for the +// start_cell offset and the optional header row. The backend's 20×200 defaults +// are kept as floors (ordinary small tables are created exactly as before) and +// its hard limits (200 cols, 50000 rows) as ceilings. +func sheetCreateDims(s *tableSheetSpec) (rows, cols int) { + _, col0, row0, _ := sheetAnchor(s) + cols = col0 + len(s.Columns) + rows = row0 + len(s.Rows) + if headerOn(s) { + rows++ + } + if cols < 20 { + cols = 20 + } + if cols > 200 { + cols = 200 + } + if rows < 200 { + rows = 200 + } + if rows > 50000 { + rows = 50000 + } + return rows, cols +} + +// listSheetIDsByName maps every existing sub-sheet's display name to its id via +// a single get_workbook_structure read. Used by write mode to decide which +// payload sheets already exist. +func listSheetIDsByName(ctx context.Context, runtime *common.RuntimeContext, token string) (map[string]string, error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return nil, err + } + m, ok := out.(map[string]interface{}) + if !ok { + return nil, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + } + sheets, _ := m["sheets"].([]interface{}) + byName := make(map[string]string, len(sheets)) + for _, raw := range sheets { + sm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := sm["sheet_id"].(string) + name, _ := sm["sheet_name"].(string) + if name == "" { + name, _ = sm["title"].(string) + } + if id != "" && name != "" { + byName[name] = id + } + } + return byName, nil +} + +// tablePutPartial builds a structured error for a multi-sheet write that failed +// partway. When some sheets already landed it's a partial_success (their +// summaries are surfaced so callers can retry the rest or delete the workbook); +// when nothing landed — the first or only sheet failed — it's a plain failure, +// so we don't misleadingly claim "some sheets were written". +func tablePutPartial(token string, spreadsheet interface{}, written []interface{}, reason string) error { + detail := map[string]interface{}{ + "spreadsheet_token": token, + "written_sheets": written, + } + if spreadsheet != nil { + detail["spreadsheet"] = spreadsheet + } + if len(written) == 0 { + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "api_error", + Message: fmt.Sprintf("table-put failed on %s: %s", token, reason), + Hint: "no sheets were written; fix the cause and retry", + Detail: detail, + }, + } + } + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_success", + Message: fmt.Sprintf("table-put partially applied to %s: %s", token, reason), + Hint: "some sheets were written; inspect written_sheets, then retry the remaining sheets or delete the spreadsheet", + Detail: detail, + }, + } +} + +// ─── dry-run ────────────────────────────────────────────────────────── + +// tablePutDryRun renders the set_cell_range write the shortcut would send for +// each sheet. Network-free; the payload and locator have already been validated +// by Validate, so errors here degrade to an empty preview rather than twice. +func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI() + payload, err := parseTablePutPayload(runtime) + if err != nil { + return dry + } + token, err := resolveSpreadsheetToken(runtime) + 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)) + 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)) + if s.Mode == "append" { + rng = "" + } + input := map[string]interface{}{ + "excel_id": token, + "sheet_name": s.Name, + "range": rng, + "cells": matrix, + } + if s.AllowOverwrite != nil && !*s.AllowOverwrite { + input["allow_overwrite"] = false + } + wireBody, _ := buildToolBody("set_cell_range", input) + dry.POST(toolInvokePath(token, ToolKindWrite)).Desc(desc).Body(wireBody) + } + return dry +} + +// ─── +table-get (typed read-back, mirror of +table-put) ─────────────── +// +// Reads a spreadsheet's sheets back into the same typed protocol +table-put +// consumes, so the output round-trips: pipe it straight back to +table-put, or +// load it into a DataFrame. Column types are inferred from each column's +// number_format (a date format → date, numeric/percent → number) and date +// serials are converted back to ISO strings — the exact inverse of the put path. + +// TableGet reads sheets back into the typed table protocol. +var TableGet = common.Shortcut{ + Service: "sheets", + Command: "+table-get", + Description: "Read sheets back into the typed table protocol (mirror of +table-put); column types are inferred from number_format so the output feeds straight to +table-put or a DataFrame.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+table-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("sheet-id")) != "" && strings.TrimSpace(runtime.Str("sheet-name")) != "" { + return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + dry := common.NewDryRunAPI() + if strings.TrimSpace(runtime.Str("sheet-id")) == "" && strings.TrimSpace(runtime.Str("sheet-name")) == "" { + body, _ := buildToolBody("get_workbook_structure", map[string]interface{}{"excel_id": token}) + dry.POST(toolInvokePath(token, ToolKindRead)).Desc("list sub-sheets via get_workbook_structure").Body(body) + } + rng := strings.TrimSpace(runtime.Str("range")) + if rng == "" { + rng = "" + } + body, _ := buildToolBody("get_cell_ranges", map[string]interface{}{ + "excel_id": token, "ranges": []string{rng}, + "include_styles": true, "value_render_option": "raw_value", + }) + dry.POST(toolInvokePath(token, ToolKindRead)). + Desc(fmt.Sprintf("read cells (%s) + styles via get_cell_ranges, then infer column types", rng)). + Body(body) + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + targets, err := tableGetTargets(ctx, runtime, token) + if err != nil { + return err + } + noHeader := runtime.Bool("no-header") + userRange := strings.TrimSpace(runtime.Str("range")) + sheets := make([]interface{}, 0, len(targets)) + for _, t := range targets { + spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader) + if err != nil { + return err + } + sheets = append(sheets, spec) + } + runtime.Out(map[string]interface{}{"sheets": sheets}, nil) + return nil + }, + Tips: []string{ + "Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.", + "Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).", + }, +} + +// tableGetSheet identifies a sheet to read back. +type tableGetSheet struct { + id string + name string +} + +// tableGetTargets resolves which sheets +table-get reads: the one named by +// --sheet-id / --sheet-name, or every sheet (in workbook order) when neither is +// given. +func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token string) ([]tableGetSheet, error) { + id := strings.TrimSpace(runtime.Str("sheet-id")) + name := strings.TrimSpace(runtime.Str("sheet-name")) + if id != "" { + return []tableGetSheet{{id: id}}, nil + } + if name != "" { + return []tableGetSheet{{name: name}}, nil + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{"excel_id": token}) + if err != nil { + return nil, err + } + m, _ := out.(map[string]interface{}) + raw, _ := m["sheets"].([]interface{}) + targets := make([]tableGetSheet, 0, len(raw)) + for _, r := range raw { + sm, ok := r.(map[string]interface{}) + if !ok { + continue + } + sid, _ := sm["sheet_id"].(string) + sname, _ := sm["sheet_name"].(string) + if sname == "" { + sname, _ = sm["title"].(string) + } + if sid != "" { + targets = append(targets, tableGetSheet{id: sid, name: sname}) + } + } + if len(targets) == 0 { + return nil, output.Errorf(output.ExitAPI, "tool_output", "no sheets found in workbook") + } + return targets, nil +} + +// readSheetAsSpec reads one sheet's region and rebuilds it as a typed-protocol +// sheet (name + typed columns + JSON-safe rows), the inverse of the put path. +func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) { + spec := map[string]interface{}{"name": t.name, "columns": []interface{}{}, "rows": []interface{}{}} + region := userRange + if region == "" { + r, err := sheetCurrentRegion(ctx, runtime, token, t.id, t.name) + if err != nil { + return nil, err + } + region = r + } + if region == "" { + return spec, nil // empty sheet + } + input := map[string]interface{}{ + "excel_id": token, + "ranges": []string{region}, + "include_styles": true, + "value_render_option": "raw_value", + "cell_limit": unboundedReadLimit, + } + sheetSelectorForToolInput(input, t.id, t.name) + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input) + if err != nil { + return nil, err + } + grid := extractCellGrid(out) + if len(grid) == 0 { + return spec, nil + } + + var headerRow []map[string]interface{} + dataRows := grid + if !noHeader { + headerRow = grid[0] + dataRows = grid[1:] + } + ncols := 0 + for _, r := range grid { + if len(r) > ncols { + ncols = len(r) + } + } + + columns := make([]interface{}, ncols) + colTypes := make([]string, ncols) + for c := 0; c < ncols; c++ { + typ, format := inferColumnType(dataRows, c) + colTypes[c] = typ + col := map[string]interface{}{"name": tableGetColumnName(headerRow, c, noHeader), "type": typ} + if format != "" { + col["format"] = format + } + columns[c] = col + } + + rows := make([]interface{}, 0, len(dataRows)) + for _, r := range dataRows { + row := make([]interface{}, ncols) + for c := 0; c < ncols; c++ { + row[c] = cellToTyped(cellAt(r, c), colTypes[c]) + } + rows = append(rows, row) + } + spec["columns"] = columns + spec["rows"] = rows + return spec, nil +} + +// sheetCurrentRegion returns the A1 range covering the sheet's existing data +// (current_region), or "" for an empty sheet. +func sheetCurrentRegion(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, sheetName string) (string, error) { + input := map[string]interface{}{"excel_id": token, "range": "A1", "max_rows": unboundedReadLimit} + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", input) + if err != nil { + return "", err + } + m, ok := out.(map[string]interface{}) + if !ok { + return "", nil + } + region, _ := m["current_region"].(string) + if region == "" { + region, _ = m["actual_range"].(string) + } + return region, nil +} + +// extractCellGrid pulls ranges[0].cells out of a get_cell_ranges response into a +// 2D grid of cell objects (each carrying value + cell_styles). +func extractCellGrid(out interface{}) [][]map[string]interface{} { + m, ok := out.(map[string]interface{}) + if !ok { + return nil + } + ranges, _ := m["ranges"].([]interface{}) + if len(ranges) == 0 { + return nil + } + r0, _ := ranges[0].(map[string]interface{}) + rawCells, _ := r0["cells"].([]interface{}) + grid := make([][]map[string]interface{}, 0, len(rawCells)) + for _, rr := range rawCells { + rowArr, _ := rr.([]interface{}) + row := make([]map[string]interface{}, 0, len(rowArr)) + for _, cc := range rowArr { + cm, _ := cc.(map[string]interface{}) + row = append(row, cm) + } + grid = append(grid, row) + } + return grid +} + +func cellAt(row []map[string]interface{}, c int) map[string]interface{} { + if c >= 0 && c < len(row) { + return row[c] + } + return nil +} + +func readCellValue(cell map[string]interface{}) interface{} { + if cell == nil { + return nil + } + return cell["value"] +} + +func readCellFormat(cell map[string]interface{}) string { + if cell == nil { + return "" + } + st, _ := cell["cell_styles"].(map[string]interface{}) + nf, _ := st["number_format"].(string) + return nf +} + +// inferColumnType decides a column's type from its data cells: a date +// number_format guides each cell's type, but a column is given a non-string type +// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a +// number column with a stray "暂无", a date column with a bare count); declaring +// number/date while a string value rides along makes the output inconsistent — +// it breaks round-trip back into +table-put (which rejects a string in a number +// column) and crashes pandas astype. So a mixed column degrades to string +// (lossless, self-consistent), keeping columns[].type faithful to every value in +// rows. Coercing dirty cells onto a numeric column is deliberately left to the +// caller (pandas to_numeric(errors="coerce") on the string column): lossless +// there — the original values stay in the frame — whereas doing it here would +// drop them silently and irrecoverably. +func inferColumnType(dataRows [][]map[string]interface{}, c int) (string, string) { + seen := map[string]bool{} + numberFormat, dateFormat := "", "" + for _, r := range dataRows { + cell := cellAt(r, c) + v := readCellValue(cell) + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue // empty string is empty, not a string value + } + nf := readCellFormat(cell) + switch { + case isDateNumberFormat(nf): + // A date format yields date only when the cell is actually a serial + // number; a date format painted on text is just text. + if _, ok := tableGetToFloat(v); ok { + seen["date"] = true + if dateFormat == "" { + dateFormat = nf + } + } else { + seen["string"] = true + } + case isTextNumberFormat(nf): + seen["string"] = true + default: + switch v.(type) { + case float64, json.Number: + seen["number"] = true + if numberFormat == "" { + numberFormat = nf + } + case bool: + seen["bool"] = true + default: + seen["string"] = true + } + } + } + switch { + case len(seen) == 0: + return "string", "" // all empty + case len(seen) == 1: + switch { + case seen["date"]: + return "date", dateFormat + case seen["number"]: + return "number", numberFormat + case seen["bool"]: + return "bool", "" + default: + return "string", "" + } + default: + return "string", "" // mixed types → string (self-consistent, lossless) + } +} + +// isDateNumberFormat reports whether a number_format denotes a date/time. Date +// formats carry a year token ('y'); pure numeric formats (#,##0, 0.00, 0.00%, +// @) do not. +func isDateNumberFormat(nf string) bool { + return strings.ContainsRune(strings.ToLower(nf), 'y') +} + +// isTextNumberFormat reports whether a number_format is Excel/Lark text format +// ("@"), which +table-put writes on string columns so digit-like strings survive +// and read back as string instead of being inferred as number. +func isTextNumberFormat(nf string) bool { + return strings.TrimSpace(nf) == "@" +} + +// cellToTyped converts a read-back cell to the JSON-safe value its column type +// implies: date serials become ISO strings, numbers/bools pass through, empty +// cells become null, everything else is stringified. inferColumnType guarantees +// a non-string column is homogeneous, so the date/number branches never meet a +// stray off-type value. +func cellToTyped(cell map[string]interface{}, typ string) interface{} { + v := readCellValue(cell) + if v == nil { + return nil + } + if s, ok := v.(string); ok && s == "" { + return nil + } + switch typ { + case "date": + if f, ok := tableGetToFloat(v); ok { + return serialToISO(f) + } + return v + case "number", "bool": + return v + default: + return stringifyCellValue(v) + } +} + +// tableGetColumnName returns the column's name: the header cell's text, or a +// positional col when there is no header row. +func tableGetColumnName(headerRow []map[string]interface{}, c int, noHeader bool) string { + if !noHeader { + if v := readCellValue(cellAt(headerRow, c)); v != nil { + if s := stringifyCellValue(v); s != "" { + return s + } + } + } + return fmt.Sprintf("col%d", c+1) +} + +func tableGetToFloat(v interface{}) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + } + return 0, false +} + +// serialToISO converts an Excel serial day number back to an ISO yyyy-mm-dd +// string — the inverse of isoDateToSerial. +func serialToISO(serial float64) string { + return excelEpoch.AddDate(0, 0, int(serial)).Format("2006-01-02") +} diff --git a/shortcuts/sheets/lark_sheet_table_io_test.go b/shortcuts/sheets/lark_sheet_table_io_test.go new file mode 100644 index 000000000..820e6eb3f --- /dev/null +++ b/shortcuts/sheets/lark_sheet_table_io_test.go @@ -0,0 +1,991 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// ─── pure helpers: date serial, typed cell mapping ──────────────────── + +func TestTablePut_IsoDateToSerial(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want int + ok bool + }{ + {"2024-01-15", 45306, true}, // the empirically verified anchor + {"2024-01-01", 45292, true}, + {"2024-02-29", 45351, true}, // 2024 is a leap year + {"1899-12-31", 1, true}, // one day after the epoch + {"not-a-date", 0, false}, + {"2024/01/15", 0, false}, // wrong separator + } + for _, tt := range cases { + got, err := isoDateToSerial(tt.in) + if tt.ok { + if err != nil { + t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want) + } + } else if err == nil { + t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got) + } + } +} + +func TestTablePut_BuildTypedCell(t *testing.T) { + t.Parallel() + + t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) { + t.Parallel() + cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123") + if err != nil { + t.Fatal(err) + } + if cell["value"] != "00123" { + t.Errorf("value = %#v, want \"00123\"", cell["value"]) + } + if nf := numberFormatOf(cell); nf != "@" { + t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf) + } + }) + + t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) { + t.Parallel() + cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345")) + if cell["value"] != "123456789012345" { + t.Errorf("value = %#v, want literal digits", cell["value"]) + } + }) + + t.Run("number preserves json.Number", func(t *testing.T) { + t.Parallel() + cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874")) + if err != nil { + t.Fatal(err) + } + if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" { + t.Errorf("value = %#v, want json.Number 259874", cell["value"]) + } + if nf := numberFormatOf(cell); nf != "#,##0" { + t.Errorf("number_format = %q, want #,##0", nf) + } + }) + + t.Run("date converts to serial + default format", func(t *testing.T) { + t.Parallel() + cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15") + if err != nil { + t.Fatal(err) + } + if cell["value"] != 45306 { + t.Errorf("value = %#v, want serial 45306", cell["value"]) + } + if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" { + t.Errorf("number_format = %q, want default yyyy-mm-dd", nf) + } + }) + + t.Run("date honors explicit format", func(t *testing.T) { + t.Parallel() + cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15") + if nf := numberFormatOf(cell); nf != "yyyy-mm" { + t.Errorf("number_format = %q, want yyyy-mm", nf) + } + }) + + t.Run("bool maps to boolean", func(t *testing.T) { + t.Parallel() + cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true) + if err != nil || cell["value"] != true { + t.Errorf("value = %#v (err=%v), want true", cell["value"], err) + } + }) + + t.Run("null is an empty cell that still carries format", func(t *testing.T) { + t.Parallel() + cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil) + if err != nil { + t.Fatal(err) + } + if _, has := cell["value"]; has { + t.Errorf("null cell should have no value: %#v", cell) + } + if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" { + t.Errorf("null date cell should still carry format, got %q", nf) + } + }) + + t.Run("type mismatches are rejected", func(t *testing.T) { + t.Parallel() + if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil { + t.Error("number column accepting a string should error") + } + if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil { + t.Error("date column accepting a number should error") + } + if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil { + t.Error("bool column accepting a string should error") + } + }) +} + +// numberFormatOf digs the number_format out of a built cell's cell_styles, or +// "" when absent. +func numberFormatOf(cell map[string]interface{}) string { + styles, ok := cell["cell_styles"].(map[string]interface{}) + if !ok { + return "" + } + nf, _ := styles["number_format"].(string) + return nf +} + +// ─── payload validation ─────────────────────────────────────────────── + +func TestTablePut_PayloadValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + json string + want string + }{ + {"empty sheets", `{"sheets":[]}`, "at least one sheet"}, + {"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"}, + {"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"}, + {"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"}, + {"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"}, + {"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"}, + {"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"}, + {"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"}, + {"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"}, + {"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"}, + {"invalid json", `{not json`, "invalid JSON"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := parseTablePutPayload(stubFlagView{"sheets": tt.json}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("want error containing %q, got %v", tt.want, err) + } + }) + } +} + +// stubFlagView is a minimal flagView backed by a map, for unit-testing the +// payload parser without a cobra command. +type stubFlagView map[string]string + +func (s stubFlagView) Str(name string) string { return s[name] } +func (s stubFlagView) Bool(name string) bool { return s[name] == "true" } +func (s stubFlagView) Int(name string) int { return 0 } +func (s stubFlagView) Float64(name string) float64 { return 0 } +func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok } +func (s stubFlagView) StrArray(name string) []string { return nil } +func (s stubFlagView) StrSlice(name string) []string { return nil } +func (s stubFlagView) Command() string { return "+table-put" } + +// ─── dry-run: create + write rendering ──────────────────────────────── + +const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` + + `{"name":"门店","type":"string"},` + + `{"name":"月份","type":"date","format":"yyyy-mm"},` + + `{"name":"销售额","type":"number","format":"#,##0"}` + + `],"rows":[["北京","2024-01-15",259874]]}]}` + +func TestTablePut_DryRunWrite(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON}) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls)) + } + body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["excel_id"] != testToken { + t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken) + } + if input["sheet_name"] != "月度" { + t.Errorf("sheet_name = %v, want 月度", input["sheet_name"]) + } + if input["range"] != "A1:C2" { + t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"]) + } + 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]) + } + data := rows[1].([]interface{}) + // 月份 (date) → serial 45306, number_format yyyy-mm + if v := cellValue(data[1]); v != float64(45306) { + t.Errorf("date cell value = %#v, want 45306 serial", v) + } + if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" { + t.Errorf("date number_format = %v, want yyyy-mm", nf) + } + // 销售额 (number) → 259874 preserved + if v := cellValue(data[2]); v != float64(259874) { + t.Errorf("number cell value = %#v, want 259874", v) + } +} + +func cellValue(c interface{}) interface{} { + m, _ := c.(map[string]interface{}) + return m["value"] +} + +func cellStyles(c interface{}) map[string]interface{} { + m, _ := c.(map[string]interface{}) + s, _ := m["cell_styles"].(map[string]interface{}) + return s +} + +// ─── validation through the cobra surface ───────────────────────────── + +func TestTablePut_Validation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "missing spreadsheet locator rejected", + args: []string{"--sheets", tablePutSheetsJSON}, + want: "at least one", + }, + { + name: "url and token are mutually exclusive", + args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON}, + want: "mutually exclusive", + }, + { + name: "bad column type rejected", + args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`}, + want: "invalid type", + }, + { + name: "row width mismatch rejected", + args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`}, + want: "column count", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} + +// ─── execute paths with stubbed tools ───────────────────────────────── + +// TestTablePut_ExecuteWrite drives the write path: a structure read maps the +// existing sheet by name, then a set_cell_range write fills it. +func TestTablePut_ExecuteWrite(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`) + write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`) + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`}, + structure, write) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 1 { + t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data) + } + s0, _ := sheets[0].(map[string]interface{}) + if s0["name"] != "数据" || s0["sheet_id"] != testSheetID { + t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID) + } + if s0["range"] != "A1:B2" { + t.Errorf("range = %v, want A1:B2", s0["range"]) + } +} + +// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the +// named sheet does not yet exist: a create precedes the write. +func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) { + t.Parallel() + // First structure read sees only "Sheet1"; the payload targets "新表", so + // createSheet runs, and the follow-up read (FIFO: second stub) resolves the + // newly created sheet's id. + structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`) + structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`) + write := toolOutputStub(testToken, "write", `{"ok":true}`) + write.Reusable = true // modify_workbook_structure create + set_cell_range + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`}, + structBefore, structAfter, write) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 1 { + t.Fatalf("result sheets = %d, want 1", len(sheets)) + } + if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 { + t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2) + } +} + +// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the +// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for +// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are +// accounted for, with columns clamped to the backend's 200 ceiling. +func TestTablePut_SheetCreateDims(t *testing.T) { + t.Parallel() + bp := func(b bool) *bool { return &b } + cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) } + rows := func(n int) [][]interface{} { return make([][]interface{}, n) } + cases := []struct { + name string + spec tableSheetSpec + wantRows, wantCols int + }{ + {"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20}, + {"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37}, + {"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20}, + {"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42}, + {"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20}, + {"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotRows, gotCols := sheetCreateDims(&tt.spec) + if gotRows != tt.wantRows || gotCols != tt.wantCols { + t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols) + } + }) + } +} + +// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the +// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must +// create it with 25 columns (past the 20-column default) so the follow-up +// set_cell_range fits instead of failing with "exceeds sheet bounds". +func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) { + t.Parallel() + structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`) + createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create + structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`) + writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range + const n = 25 + cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",") + vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",") + payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}` + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", payload}, + structBefore, createStub, structAfter, writeStub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + var wire map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil { + t.Fatalf("decode create body: %v", err) + } + var input map[string]interface{} + if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil { + t.Fatalf("decode create tool input: %v", err) + } + if input["operation"] != "create" { + t.Fatalf("first write should be the create op, got %#v", input["operation"]) + } + if input["columns"] != float64(n) { + t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n) + } + if input["rows"] != float64(200) { + t.Errorf("create rows = %#v, want 200 (floor)", input["rows"]) + } +} + +// TestTablePut_ExecutePartialFailure covers the partial-success error path: +// a set_cell_range write fails mid-import and the structured error surfaces. +// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed +// nothing — it must be a plain failure, NOT partial_success. +func TestTablePut_ExecuteTotalFailure(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`) + writeErr := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write", + Body: map[string]interface{}{"code": 1254000, "msg": "boom"}, + } + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`}, + structure, writeErr) + if err == nil { + t.Fatalf("expected failure; got nil. out=%s", out) + } + if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") { + t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out) + } + if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") { + t.Errorf("expected plain-failure message; got err=%v out=%s", err, out) + } +} + +// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails → +// partial_success carrying the first sheet in written_sheets. +func TestTablePut_ExecutePartialFailure(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", + `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`) + writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`) + writeErr := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write", + Body: map[string]interface{}{"code": 1254000, "msg": "boom"}, + } + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`}, + structure, writeOK, writeErr) + if err == nil { + t.Fatalf("expected partial-success error; got nil. out=%s", out) + } + if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") { + t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out) + } + // The failing sheet is named in the message; the written one lives in the + // structured written_sheets detail. + if !strings.Contains(err.Error(), "明细") { + t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err) + } +} + +// ─── +workbook-create typed --sheets path ───────────────────────────── + +// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed +// --sheets entry can't be combined with the untyped --headers/--values. +func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) { + t.Parallel() + typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}` + for _, tc := range []struct { + 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) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args) + if err == nil { + t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr) + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("want 'mutually exclusive' error; got %v", err) + } + }) + } +} + +// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but +// empty --sheets (e.g. empty stdin / file): it must error, not silently fall +// through to creating an empty workbook. +func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""}) + if err == nil { + t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr) + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("want 'empty' error; got %v", err) + } +} + +// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create: +// the new workbook's default sheet is renamed to the first payload sheet's name +// and reused (no empty Sheet1 left behind), then written type-faithfully (the +// date lands as an Excel serial, not text). +func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"}, + }, + }, + } + // lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the + // structure; one reusable stub serves both, reporting only the default sheet. + structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`) + structure.Reusable = true + rename := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write", + BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") }, + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}}, + } + write := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write", + BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") }, + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}}, + } + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ + "--title", "Demo", + "--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`, + }, create, structure, rename, write) + if err != nil { + t.Fatalf("typed create failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" { + t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"]) + } + if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 { + t.Fatalf("want 1 written sheet, got %#v", data["sheets"]) + } + // Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no + // stray Sheet1). + renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure") + if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" { + t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput) + } + // The data write carries the date as serial 45306, proving the type-faithful path. + writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range") + cellsJSON, _ := json.Marshal(writeInput["cells"]) + if !strings.Contains(string(cellsJSON), "45306") { + t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON) + } +} + +// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed +// set_cell_range write with the date already converted to a serial. +func TestWorkbookCreate_TypedDryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{ + "--title", "Demo", + "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`, + }) + if len(calls) != 2 { + t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls)) + } + raw, _ := json.Marshal(calls[1]) + if !strings.Contains(string(raw), "45306") { + t.Errorf("typed dry-run write should contain serial 45306; got %s", raw) + } +} + +func TestTablePut_StringifyCellValue(t *testing.T) { + t.Parallel() + cases := []struct { + in interface{} + want string + }{ + {"plain", "plain"}, + {json.Number("12345678901234"), "12345678901234"}, + {true, "TRUE"}, + {false, "FALSE"}, + {3.5, "3.5"}, + } + for _, tt := range cases { + if got := stringifyCellValue(tt.in); got != tt.want { + t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestTablePut_DescribeJSONType(t *testing.T) { + t.Parallel() + cases := []struct { + in interface{} + want string + }{ + {"x", "a string"}, + {json.Number("1"), "a number"}, + {true, "a boolean"}, + {[]interface{}{}, "an array"}, + {map[string]interface{}{}, "an object"}, + {3.14, "float64"}, + } + for _, tt := range cases { + if got := describeJSONType(tt.in); got != tt.want { + t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestTablePut_HeaderAndMode(t *testing.T) { + t.Parallel() + bp := func(b bool) *bool { return &b } + // headerOn: overwrite writes header, append omits it by default, explicit wins + if !headerOn(&tableSheetSpec{}) { + t.Error("overwrite default should write header") + } + if headerOn(&tableSheetSpec{Mode: "append"}) { + t.Error("append should omit header by default") + } + if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) { + t.Error("explicit header:true should override append default") + } + if headerOn(&tableSheetSpec{Header: bp(false)}) { + t.Error("explicit header:false should be honored") + } + // writeModeName + if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" { + t.Error("writeModeName normalization wrong") + } + // buildSheetMatrix header toggle + s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}} + if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 { + t.Errorf("header off → 1 data row, got %d", len(m)) + } + if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 { + t.Errorf("header on → header + 1 data row, got %d", len(m)) + } +} + +func TestTablePut_BadModeRejected(t *testing.T) { + t.Parallel() + _, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`}) + if err == nil || !strings.Contains(err.Error(), "invalid") { + t.Errorf("mode \"upsert\" should be rejected, got %v", err) + } +} + +// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still +// writes the header row, so column names aren't lost (and a later +table-get +// won't consume the first data row as the header). +func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`) + region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0 + write := toolOutputStub(testToken, "write", `{"ok":true}`) + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`}, + structure, region, write) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + var wire map[string]interface{} + if err := json.Unmarshal(write.CapturedBody, &wire); err != nil { + t.Fatalf("decode captured write body: %v", err) + } + var input map[string]interface{} + if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil { + t.Fatalf("decode tool input: %v", err) + } + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells)) + } + if header, _ := cells[0].([]interface{}); len(header) > 0 { + if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" { + t.Errorf("first row should be the header 列A; got %#v", h0) + } + } + if input["range"] != "A1:A3" { + t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"]) + } +} + +// TestTablePut_ExecuteAppend verifies append placement: data lands below the +// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated +// header. +func TestTablePut_ExecuteAppend(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`) + region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`) + write := toolOutputStub(testToken, "write", `{"ok":true}`) + out, err := runShortcutWithStubs(t, TablePut, + []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`}, + structure, region, write) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + // inspect the set_cell_range request the append produced + var wire map[string]interface{} + if err := json.Unmarshal(write.CapturedBody, &wire); err != nil { + t.Fatalf("decode captured write body: %v", err) + } + var input map[string]interface{} + if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil { + t.Fatalf("decode tool input: %v", err) + } + if input["range"] != "A6:B7" { + t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"]) + } + if cells, _ := input["cells"].([]interface{}); len(cells) != 2 { + t.Errorf("append should write 2 data rows (no header), got %d", len(cells)) + } + data := decodeEnvelopeData(t, out) + if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" { + t.Errorf("summary mode = %v, want append", s0["mode"]) + } +} + +// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the +// header row and allow_overwrite:false reaches the tool input. +func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", + `{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`}) + body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["allow_overwrite"] != false { + t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"]) + } + rows, _ := input["cells"].([]interface{}) + if len(rows) != 2 { + t.Fatalf("header:false → 2 data rows only, got %d", len(rows)) + } + first, _ := rows[0].([]interface{})[0].(map[string]interface{}) + if first["value"] != "x" { + t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"]) + } +} + +// ─── +table-get ─────────────────────────────────────────────────────── + +func TestTableGet_SerialRoundTrip(t *testing.T) { + t.Parallel() + for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} { + s, err := isoDateToSerial(iso) + if err != nil { + t.Fatalf("isoDateToSerial(%s): %v", iso, err) + } + if back := serialToISO(float64(s)); back != iso { + t.Errorf("roundtrip %s → %d → %s", iso, s, back) + } + } +} + +func TestTableGet_IsDateNumberFormat(t *testing.T) { + t.Parallel() + for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} { + if !isDateNumberFormat(nf) { + t.Errorf("%q should be a date format", nf) + } + } + for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} { + if isDateNumberFormat(nf) { + t.Errorf("%q should not be a date format", nf) + } + } +} + +func TestTableGet_InferColumnType(t *testing.T) { + t.Parallel() + mk := func(v interface{}, nf string) map[string]interface{} { + c := map[string]interface{}{"value": v} + if nf != "" { + c["cell_styles"] = map[string]interface{}{"number_format": nf} + } + return c + } + col := func(cells ...map[string]interface{}) [][]map[string]interface{} { + rows := make([][]map[string]interface{}, len(cells)) + for i, c := range cells { + rows[i] = []map[string]interface{}{c} + } + return rows + } + if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" { + t.Errorf("date col → %s/%s", typ, f) + } + if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" { + t.Errorf("number col → %s/%s", typ, f) + } + if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" { + t.Errorf("bool col → %s", typ) + } + if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" { + t.Errorf("string col → %s", typ) + } + // digit-like value carrying text format (@) infers as string, not number — + // this is what makes +table-put's string columns (ids/postcodes) survive read-back. + if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" { + t.Errorf("@-format numeric-looking col → %s, want string", typ) + } + if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" { + t.Errorf("empty col → %s (want string)", typ) + } + + // Mixed number+text degrades to string (self-consistent: every value is then + // a string), so the column round-trips and pandas doesn't choke. Numeric + // coercion of the dirty cells is left to the caller (pandas to_numeric). + if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" { + t.Errorf("mixed number+text col → %s, want string", typ) + } + // A bare number mixed into a date column must NOT stay date (would serial- + // convert the number into a bogus date) — degrades to string. + if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" { + t.Errorf("date+bare-number col → %s, want string", typ) + } +} + +func TestTableGet_CellToTyped(t *testing.T) { + t.Parallel() + mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} } + if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" { + t.Errorf("date serial → %v, want 2024-01-15", v) + } + if v := cellToTyped(mk(100.0), "number"); v != 100.0 { + t.Errorf("number → %v", v) + } + if v := cellToTyped(mk(true), "bool"); v != true { + t.Errorf("bool → %v", v) + } + if v := cellToTyped(mk(""), "string"); v != nil { + t.Errorf("empty string → %v, want nil", v) + } + if v := cellToTyped(nil, "string"); v != nil { + t.Errorf("nil → %v, want nil", v) + } + if v := cellToTyped(mk("hi"), "string"); v != "hi" { + t.Errorf("string → %v", v) + } +} + +// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text +// format @) reads back as string, not number — so leading-zero ids / postcodes +// survive instead of collapsing to a number. +func TestTableGet_DigitStringRoundTrip(t *testing.T) { + t.Parallel() + region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`) + cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+ + `[{"value":"邮编"}],`+ + `[{"value":"00123","cell_styles":{"number_format":"@"}}]`+ + `]}]}`) + out, err := runShortcutWithStubs(t, TableGet, + []string{"--url", testURL, "--sheet-name", "S"}, region, cells) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + s0, _ := sheets[0].(map[string]interface{}) + cols, _ := s0["columns"].([]interface{}) + if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" { + t.Errorf("@-format col 邮编 → type %v, want string", c0["type"]) + } + rows, _ := s0["rows"].([]interface{}) + if r0, _ := rows[0].([]interface{}); r0[0] != "00123" { + t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0]) + } +} + +// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the +// same typed protocol +table-put consumes: date serial → ISO, number preserved, +// types inferred from number_format. +func TestTableGet_ExecuteRoundTrip(t *testing.T) { + t.Parallel() + region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`) + cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+ + `[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+ + `[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+ + `]}]}`) + out, err := runShortcutWithStubs(t, TableGet, + []string{"--url", testURL, "--sheet-name", "销售"}, region, cells) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 1 { + t.Fatalf("want 1 sheet, got %d", len(sheets)) + } + s0, _ := sheets[0].(map[string]interface{}) + if s0["name"] != "销售" { + t.Errorf("name = %v, want 销售", s0["name"]) + } + cols, _ := s0["columns"].([]interface{}) + if len(cols) != 3 { + t.Fatalf("want 3 columns, got %d", len(cols)) + } + c1, _ := cols[1].(map[string]interface{}) + if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" { + t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1) + } + c2, _ := cols[2].(map[string]interface{}) + if c2["type"] != "number" || c2["format"] != "#,##0" { + t.Errorf("col 销售额 = %#v, want number #,##0", c2) + } + rows, _ := s0["rows"].([]interface{}) + r0, _ := rows[0].([]interface{}) + if r0[1] != "2024-01-15" { + t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1]) + } + if r0[2] != float64(259874) { + t.Errorf("number = %v, want 259874", r0[2]) + } +} + +func TestTableGet_DryRunIncludesCellRead(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"}) + found := false + for _, c := range calls { + body, _ := c.(map[string]interface{})["body"].(map[string]interface{}) + if body == nil { + continue + } + if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" { + found = true + } + } + if !found { + t.Error("dry-run should include a get_cell_ranges read") + } +} + +// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name): +// get_workbook_structure lists sheets, then each is read in order. +func TestTableGet_AllSheets(t *testing.T) { + t.Parallel() + structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`) + regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`) + cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`) + regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`) + cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`) + out, err := runShortcutWithStubs(t, TableGet, + []string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 2 { + t.Fatalf("want 2 sheets (all), got %d", len(sheets)) + } + got := []string{ + sheets[0].(map[string]interface{})["name"].(string), + sheets[1].(map[string]interface{})["name"].(string), + } + if got[0] != "A" || got[1] != "B" { + t.Errorf("sheet names = %v, want [A B] in workbook order", got) + } +} diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index cf10e1ee6..81b2e6e8e 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -565,7 +565,7 @@ var SheetHideGridline = newSheetVisibilityShortcut( var WorkbookCreate = common.Shortcut{ Service: "sheets", Command: "+workbook-create", - Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).", + Description: "Create a new spreadsheet, optionally pre-filled with untyped --headers/--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"}, @@ -575,6 +575,20 @@ var WorkbookCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("title")) == "" { 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. + 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") + } + _, err := parseTablePutPayload(runtime) + return err + } if runtime.Str("headers") != "" { v, err := parseJSONFlag(runtime, "headers") if err != nil { @@ -610,6 +624,29 @@ 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) + } + } + return dry + } if fill, _ := buildInitialFillInput(runtime); fill != nil { fill["excel_id"] = "" fill["sheet_id"] = "" // resolved from the workbook at execute time @@ -640,6 +677,30 @@ 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 + } + 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)) + } + written, err := writeTypedSheets(ctx, runtime, token, payload, runtime.Bool("header-style"), firstSheetID) + if err != nil { + return workbookCreatedButFillFailed(token, ss, + fmt.Sprintf("typed 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. @@ -669,6 +730,7 @@ var WorkbookCreate = common.Shortcut{ }, 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).", }, } diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 6a7fc5d19..1bef0fa83 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -58,6 +58,7 @@ func shortcutList() []common.Shortcut { CellsGet, CsvGet, DropdownGet, + TableGet, // lark_sheet_search_replace CellsSearch, @@ -69,6 +70,7 @@ func shortcutList() []common.Shortcut { CellsSetImage, CsvPut, DropdownSet, + TablePut, // lark_sheet_range_operations CellsClear, diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 3c242f9ef..eec770d3e 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object) — 必填 { row: number, col: string } +- `position` (object?) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object) — 必填 { width: number, height: number } +- `size` (object?) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index ed54e4830..a2b449d4c 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -15,17 +15,18 @@ ## 使用场景 -读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut,按读取目的选择: +读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut,按读取目的选择: | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| -| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | +| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | +| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[{name,type}]` + `rows`),列类型由 `number_format` 推断、混合列无损降 `string`;类型保真往返 | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | | 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | **选择原则**: - 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 -- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token,超大表批量仍用默认) +- 要按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get` - 需要公式/样式/批注 → `+cells-get` - 只想知道某区域下拉框有哪些选项 → `+dropdown-get` @@ -83,6 +84,7 @@ | `+cells-get` | read | 单元格 | | `+dropdown-get` | read | 对象 | | `+csv-get` | read | 单元格 | +| `+table-get` | read | 单元格 | ## Flags @@ -115,7 +117,17 @@ _公共四件套 · 系统:`--dry-run`_ | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | -| `--rows-json` | bool | optional | 返回结构化 rows(`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` | + +### `+table-get` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 | +| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 | +| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的当前数据区 | +| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) | ## Examples @@ -140,17 +152,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" - `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界**,**优先于 `+workbook-info` 的 `row_count`**(`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行) - `has_more` — 是否截断;截断后续读用 `--range` 接着读 -**加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)** - -```bash -lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json -``` - -`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`): - -- `rows` — 数组,每元素 `{row_number, values}`。`row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key(如 `values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。 -- `data_not_fully_read` — **仅当没读全时出现**:`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。 -- 其余字段(`current_region` / `actual_range` / `has_more`)同上。 +> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。 ### `+cells-get` @@ -164,6 +166,27 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she > ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。 +### `+table-get`(飞书 → DataFrame,类型保真读出) + +`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 同构的 typed 协议(`sheets[]` + `columns:[{name,type}]` + `rows`),可直接喂回 `+table-put` 或转 DataFrame。列 `type` 从每列 `number_format` 推断(日期格式→`date`、数值→`number`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`,让 `columns[].type` 与 `rows` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。 + +```bash +# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame) +lark-cli sheets +table-get --url "<表URL>" +# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部) +lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" +``` + +`+table-get` 输出 → DataFrame(按读回的 `type` 还原 dtype): + +```python +sheet = out["data"]["sheets"][0] +df = pd.DataFrame(sheet["rows"], columns=[c["name"] for c in sheet["columns"]]) +for c in sheet["columns"]: + if c["type"] == "date": df[c["name"]] = pd.to_datetime(df[c["name"]]) + elif c["type"] == "number": df[c["name"]] = pd.to_numeric(df[c["name"]]) +``` + ### Validate / DryRun / Execute 约束 - `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。 diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 8b5b45634..adb997a69 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -139,6 +139,8 @@ _系统:`--dry-run`_ | `--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) | ### `+workbook-export` @@ -150,6 +152,23 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag,与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) | | `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 | +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+workbook-create` `--sheets` + +_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_ + +**数组项**(类型 object): +- `name` (string) — 目标子表名 +- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1" +- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append] +- `header` (boolean?) — 是否写一行列名表头 +- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success) +- `columns` (array) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string } +- `rows` (array>) — 数据行;每行是一个数组,长度必须等于 columns 数 + ## Examples 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。 @@ -158,6 +177,29 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ 输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 +### `+workbook-create` + +新建电子表格,可选预填数据。两种数据入口**互斥**,按需二选一: + +```bash +# 1) untyped:--headers + --values(纯值;类型由飞书自动识别,日期会落成文本) +lark-cli sheets +workbook-create --title "销售" \ + --headers '["门店","销售额"]' --values '[["北京",259874]]' + +# 2) typed:--sheets(一步建表 + 类型保真)。date 列落成真日期(可排序/透视)、 +# number 不丢精度、string 列保前导零(如订单号 00123);多子表一次建。 +lark-cli sheets +workbook-create --title "交易" --sheets '{ + "sheets":[ + {"name":"明细","columns":[ + {"name":"日期","type":"date"}, + {"name":"金额","type":"number","format":"#,##0.00"}, + {"name":"单号","type":"string"} + ],"rows":[["2024-01-15",1234.5,"00123"]]} + ]}' +``` + +`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令,是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip)。 + ### `+sheet-create` 示例: @@ -214,6 +256,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`。 +- `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`)。 - `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 dd772f246..914ee5285 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -44,7 +44,30 @@ ## 使用场景 -写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。** +写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut,按数据来源 + 内容形态选: + +| 场景 | 用这个 shortcut | 原因 | +|------|----------------|------| +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 带类型的结构化数据(DataFrame)→ 飞书,要类型保真 | `+table-put` | 列显式声明 `type`:date 落真日期、**金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` | +| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | +| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | +| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | +| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | + +**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(声明 `number` / `date` 类型 + `number_format`),而不是 `+csv-put`。 + +⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 + +⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**(高频反模式):金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put` 声明该列 `type:"number"`(百分比存小数,如 `0.305`)+ `format`(如 `"$#,##0.00"` / `"0.0%"` / `"#,##0"`),**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。 + +⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 + +## `+cells-set` 写入要点(高频模式 / 公式 / 样式) + +> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的高频模式与铁律;选哪个 shortcut 见上方「使用场景」。 + +`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。 > **单元格图片 vs 浮动图片**: > - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 @@ -208,24 +231,6 @@ lark-cli sheets +dropdown-set \ `+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 -## 工具选择 - -本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选: - -| 场景 | 用这个 shortcut | 原因 | -|------|----------------|------| -| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | -| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | -| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | -| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | -| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | - -**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`。 - -⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 - -⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 - ## Shortcuts | Shortcut | Risk | 分组 | @@ -235,6 +240,7 @@ lark-cli sheets +dropdown-set \ | `+cells-set-image` | write | 单元格 | | `+dropdown-set` | write | 对象 | | `+csv-put` | write | 单元格 | +| `+table-put` | write | 单元格 | ## Flags @@ -303,6 +309,15 @@ _公共四件套 · 系统:`--dry-run`_ | `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 | | `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17)时自动取其左上角单元格(隐藏 flag:不在 `--help` 列出,但可正常传入) | +### `+table-put` + +_公共: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 > 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 @@ -338,6 +353,19 @@ _列表选项_ **数组项**(类型 string): - 标量:string +### `+table-put` `--sheets` + +_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_ + +**数组项**(类型 object): +- `name` (string) — 目标子表名 +- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1" +- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append] +- `header` (boolean?) — 是否写一行列名表头 +- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success) +- `columns` (array) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string } +- `rows` (array>) — 数据行;每行是一个数组,长度必须等于 columns 数 + ## Examples 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 @@ -430,6 +458,44 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ > - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。 > - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。 +### `+table-put`(DataFrame → 飞书,类型保真写入) + +把带类型的结构化数据(DataFrame)类型保真地写入**已有**表,底层复用 `set_cell_range`(同 `+cells-set`)。typed 协议:顶层 `sheets[]`,每 sheet 带 `columns:[{name,type,format?}]` + `rows`(二维数组,`null`=空单元格),列 `type` ∈ `string` / `number` / `date` / `bool`(**显式声明**,不让 CLI 猜,避免邮编 / 订单号等"像数字的文本"被误判)。`date` 列的 ISO `yyyy-mm-dd` 字符串会转成 Excel 序列号 + 日期 `number_format`(真日期,可排序 / 透视 / 筛选)。 + +只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——要新表先 `+workbook-create` 拿 token 再写。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。 + +```bash +# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet +python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets - +# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头 +lark-cli sheets +table-put --spreadsheet-token "" --sheets @payload.json +``` + +每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。 + +**前提:此 helper 需 pandas。** 注意一台机器常装多个 Python,`python3` 未必指向装了 pandas 的那个——撞 `ModuleNotFoundError` 就换个解释器(如 `/usr/bin/python3`)再试。**不想依赖 pandas 也行**:typed 协议就是纯 JSON,直接手写 `columns` + `rows`(不经 helper)一样喂给 `--sheets -`。DataFrame → 协议 的薄 helper(一次清洗:`NaN→null`、`Timestamp→ISO`、`numpy 标量→原生`): + +```python +import pandas as pd, numpy as np +def df_to_sheet(df, name, formats=None): + formats = formats or {} + def coltype(s): + if pd.api.types.is_datetime64_any_dtype(s): return "date" + if pd.api.types.is_bool_dtype(s): return "bool" + if pd.api.types.is_numeric_dtype(s): return "number" + return "string" + def cell(v): + if pd.isna(v): return None + if isinstance(v, pd.Timestamp): return v.date().isoformat() + if isinstance(v, np.generic): return v.item() + return v + columns = [{"name": str(c), "type": coltype(df[c]), + **({"format": formats[c]} if c in formats else {})} for c in df.columns] + rows = [[cell(v) for v in r] for r in df.itertuples(index=False, name=None)] + return {"name": name, "columns": columns, "rows": rows} +# payload = {"sheets": [df_to_sheet(df, "销售", {"日期": "yyyy-mm-dd"})]};json.dump 经 stdin 喂给 +table-put --sheets - +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。