mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
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)
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
1148
shortcuts/sheets/lark_sheet_table_io.go
Normal file
File diff suppressed because it is too large
Load Diff
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
991
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write typed sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
return dry
|
||||
}
|
||||
if fill, _ := buildInitialFillInput(runtime); fill != nil {
|
||||
fill["excel_id"] = "<new-token>"
|
||||
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
|
||||
@@ -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).",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
|
||||
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 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 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
|
||||
- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。
|
||||
|
||||
@@ -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<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
|
||||
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 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 "<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 解析;防爆参数上限校验。
|
||||
|
||||
Reference in New Issue
Block a user