Compare commits

..

2 Commits

Author SHA1 Message Date
zhengzhijie
8403e3082b feat(sheets): enable bitable sheet creation 2026-07-09 20:49:44 +08:00
zhengzhijie
34f16ad9c1 fix(sheets): hide bitable sheet creation 2026-07-09 20:49:05 +08:00
18 changed files with 97 additions and 252 deletions

View File

@@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_sheet_cli_zzj_dev")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}

View File

@@ -915,63 +915,3 @@ func TestBatchOp_RequiredFlagParity(t *testing.T) {
})
}
}
func TestBatchOp_EnumParity(t *testing.T) {
t.Parallel()
t.Run("canonical casing is normalized before translation", func(t *testing.T) {
t.Parallel()
got, err := translateBatchOp(map[string]interface{}{
"shortcut": "+cells-clear",
"input": map[string]interface{}{
"sheet-id": "sh1",
"range": "A1:B2",
"scope": "FORMATS",
},
}, testToken, 0)
if err != nil {
t.Fatalf("translateBatchOp: %v", err)
}
input, _ := got["input"].(map[string]interface{})
if input["clear_type"] != "formats" {
t.Fatalf("clear_type = %v, want formats", input["clear_type"])
}
})
tests := []struct {
name string
shortcut string
input map[string]interface{}
want string
}{
{
name: "invalid clear scope",
shortcut: "+cells-clear",
input: map[string]interface{}{
"sheet-id": "sh1", "range": "A1:B2", "scope": "formtas",
},
want: "invalid value \"formtas\" for --scope",
},
{
name: "invalid copy paste type",
shortcut: "+range-copy",
input: map[string]interface{}{
"sheet-id": "sh1", "source-range": "A1:B2", "target-range": "D1", "paste-type": "valuez",
},
want: "invalid value \"valuez\" for --paste-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(map[string]interface{}{
"shortcut": tt.shortcut,
"input": tt.input,
}, testToken, 0)
validationErr := requireValidation(t, err, tt.want)
if validationErr.Param != "--operations" {
t.Errorf("param = %q, want --operations", validationErr.Param)
}
})
}
}

View File

@@ -380,9 +380,6 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
if err := fv.validateRawTypes(); err != nil {
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
}
if err := fv.normalizeAndValidateEnums(); err != nil {
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))

View File

@@ -159,10 +159,11 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet); default sheet.",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet",
"enum": [
"sheet"
"sheet",
"bitable"
]
},
{
@@ -2860,7 +2861,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -3013,7 +3014,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3137,7 +3138,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -4638,7 +4639,7 @@
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
},
{
"name": "position-row",
@@ -4754,15 +4755,15 @@
"name": "image-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"
"required": "xor",
"desc": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"
},
{
"name": "image-uri",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"
"required": "xor",
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
},
{
"name": "position-row",

View File

@@ -93,36 +93,6 @@ func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
}
}
// TestExecute_RevisionGet_WikiURL guards RevisionGet's custom Execute hook:
// the wiki node token must be resolved before get_workbook_structure runs.
func TestExecute_RevisionGet_WikiURL(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "read", `{"revision":60}`)
out, err := runShortcutWithStubs(t, RevisionGet,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["revision"] != float64(60) {
t.Fatalf("revision = %v, want 60; out=%s", data["revision"], out)
}
}
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
// non-spreadsheet obj_type before any tool invoke.
func TestExecute_WikiURLWrongObjType(t *testing.T) {

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,7 +38,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
@@ -475,7 +475,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -588,7 +588,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
@@ -632,8 +632,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
@@ -828,7 +828,7 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet); default sheet.", Default: "sheet", Enum: []string{"sheet"}},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet", Enum: []string{"sheet", "bitable"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"math"
"slices"
"strconv"
"strings"
)
@@ -119,21 +118,20 @@ func (m mapFlagView) lookup(name string) (interface{}, bool) {
// lookupRaw resolves a flag name against the user-supplied input only, trying
// the exact key then the hyphen↔underscore variants.
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
_, v, ok := m.lookupRawWithKey(name)
return v, ok
}
func (m mapFlagView) lookupRawWithKey(name string) (string, interface{}, bool) {
for _, key := range []string{
name,
strings.ReplaceAll(name, "-", "_"),
strings.ReplaceAll(name, "_", "-"),
} {
if v, ok := m.raw[key]; ok {
return key, v, true
if v, ok := m.raw[name]; ok {
return v, true
}
if alt := strings.ReplaceAll(name, "-", "_"); alt != name {
if v, ok := m.raw[alt]; ok {
return v, true
}
}
return "", nil, false
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
if v, ok := m.raw[alt]; ok {
return v, true
}
}
return nil, false
}
func (m mapFlagView) Str(name string) string {
@@ -301,47 +299,6 @@ func (m mapFlagView) validateRawTypes() error {
return nil
}
// normalizeAndValidateEnums applies the same flat string-enum contract as the
// standalone cobra path. Canonical casing and known aliases are rewritten in
// place; unknown values are rejected before a translator can silently fall
// back to a different operation.
func (m *mapFlagView) normalizeAndValidateEnums() error {
defs, err := loadFlagDefs()
if err != nil {
return nil //nolint:nilerr // match validateRawTypes: missing embedded metadata must not block the batch
}
spec, ok := defs[m.command]
if !ok {
return nil
}
for _, df := range spec.Flags {
if df.Kind == "system" || df.Type != "string" || len(df.Enum) == 0 {
continue
}
rawKey, raw, changed := m.lookupRawWithKey(df.Name)
if !changed {
continue
}
value, ok := raw.(string)
if !ok {
return fmt.Errorf("--%s must be a string, got %s", df.Name, jsonTypeName(raw)) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
}
if value == "" || slices.Contains(df.Enum, value) {
continue
}
if canonical := canonicalEnumValue(value, df.Enum); canonical != "" {
m.raw[rawKey] = canonical
continue
}
message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", "))
if match := closestEnumValue(value, df.Enum); match != "" {
message += fmt.Sprintf("; did you mean %q?", match)
}
return fmt.Errorf("%s", message) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
}
return nil
}
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
// type-mismatch error messages.
func jsonTypeName(v interface{}) string {

View File

@@ -3,7 +3,10 @@
package sheets
import "testing"
import (
"strings"
"testing"
)
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
// is only sent when explicitly provided, otherwise the server defaults to the
@@ -50,37 +53,35 @@ func TestChangesetGet_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantMsg string
wantParam string
name string
args []string
wantSub string
}{
{
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantMsg: "start-revision must be >= 1",
wantParam: "--start-revision",
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantSub: "start-revision must be >= 1",
},
{
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantMsg: "end-revision",
wantParam: "--end-revision",
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantSub: "end-revision",
},
{
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantMsg: "version gap exceeds limit",
wantParam: "--end-revision",
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantSub: "version gap exceeds limit",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
validationErr := requireValidation(t, err, c.wantMsg)
if validationErr.Param != c.wantParam {
t.Errorf("param = %q, want %q", validationErr.Param, c.wantParam)
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
}
})
}

View File

@@ -510,15 +510,13 @@ func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension strin
}
type resizeOp struct {
start int
end int
rangeKey string
input map[string]interface{}
start int
input map[string]interface{}
}
ops := make([]resizeOp, 0, len(entries))
seen := make(map[string]string, len(entries)) // normalized range → original key
for key, raw := range entries {
parsedDim, startIdx, endIdx, err := parseA1Range(key)
parsedDim, startIdx, _, err := parseA1Range(key)
if err != nil {
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q: %v", mapFlag, key, err)
}
@@ -573,29 +571,10 @@ func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension strin
} else {
opInput["resize_width"] = sizeBlock
}
ops = append(ops, resizeOp{
start: startIdx,
end: endIdx,
rangeKey: normalized,
input: opInput,
})
ops = append(ops, resizeOp{start: startIdx, input: opInput})
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].start != ops[j].start {
return ops[i].start < ops[j].start
}
return ops[i].end < ops[j].end
})
for i := 1; i < len(ops); i++ {
if ops[i].start <= ops[i-1].end {
return nil, sheetsValidationForFlag(
mapFlag,
"--%s ranges %q and %q overlap; use non-overlapping ranges",
mapFlag, ops[i-1].rangeKey, ops[i].rangeKey,
)
}
}
sort.Slice(ops, func(i, j int) bool { return ops[i].start < ops[j].start })
operations := make([]interface{}, 0, len(ops))
for _, op := range ops {
operations = append(operations, map[string]interface{}{

View File

@@ -423,18 +423,6 @@ func TestResize_MapFormGuards(t *testing.T) {
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100, "A:A": 120}`},
want: "target the same range A:A",
},
{
name: "--widths rejects overlapping ranges with the same start",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A:C": 100, "A:F": 120}`},
want: `ranges "A:C" and "A:F" overlap`,
},
{
name: "--heights rejects overlapping ranges with different starts",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"2:10": 30, "5:20": 40}`},
want: `ranges "2:10" and "5:20" overlap`,
},
{
name: "--widths char-unit width rejected with conversion hint",
sc: ColsResize,

View File

@@ -42,7 +42,7 @@ var RevisionGet = common.Shortcut{
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -3,11 +3,7 @@
package sheets
import (
"testing"
"github.com/larksuite/cli/errs"
)
import "testing"
func TestRevisionGetProjectRevision(t *testing.T) {
t.Parallel()
@@ -28,12 +24,14 @@ func TestRevisionGetProjectRevision(t *testing.T) {
t.Run("errors when revision is absent", func(t *testing.T) {
out := map[string]interface{}{"sheets": []interface{}{}}
_, err := projectRevision(out)
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "revision")
if _, err := projectRevision(out); err == nil {
t.Error("expected an error when revision is missing, got nil")
}
})
t.Run("errors on a non-object output", func(t *testing.T) {
_, err := projectRevision("not-an-object")
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "non-object")
if _, err := projectRevision("not-an-object"); err == nil {
t.Error("expected an error for non-object output, got nil")
}
})
}

View File

@@ -123,12 +123,25 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" {
return nil, common.ValidationErrorf("--type must be 'sheet'")
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")

View File

@@ -281,12 +281,6 @@ func TestWorkbook_Validation(t *testing.T) {
args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"},
wantMsg: "--row-count must be between",
},
{
name: "+sheet-create rejects hidden bitable type",
sc: SheetCreate,
args: []string{"--url", testURL, "--title", "Tasks", "--type", "bitable"},
wantMsg: `invalid value "bitable" for --type`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -65,7 +65,7 @@ metadata:
| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find``+find``--query` |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list``+workbook-get``+workbook-list` |
| 复核某次AI编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20 | `lark-sheets-changeset` | — |
| 复核某次AI编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20 | — |
| 取当前文档 revision版本号 | `+revision-get` | `lark-sheets-workbook` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**给它加子表却用它——import 只会新建独立表,加子表应走 `+sheet-copy` / `+sheet-create` |

View File

@@ -59,7 +59,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet所有 range 应用同一组 style |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet所有 range 应用同一组 style |
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff` |
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000` |
| `--font-family` | string | optional | 字体名称(如 `Arial``微软雅黑` |
@@ -79,7 +79,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个,`["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
| `--options` | string + File + Stdin复合 JSON | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
| `--colors` | string + File + Stdin简单 JSON | optional | 下拉胶囊背景色RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**`--highlight=false` 时被忽略。 |
| `--multiple` | bool | optional | 启用多选 |
@@ -100,7 +100,7 @@ _公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet对所有 range 执行同一 scope 的清除 |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet对所有 range 执行同一 scope 的清除 |
| `--scope` | string | optional | 清除范围 enum`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all` |
## Schemas

View File

@@ -60,7 +60,7 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png` |
| `--image-token` | string | xor | 图片 file_token`--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | xor | 图片 URI上传链路返回的句柄非表内对象 reference_id`--image-token` 二选一);系统自动转换为 file_token |
| `--image-uri` | string | xor | 图片 reference_id`--image-token` 二选一);图片上传链路返回的 reference_id |
| `--position-row` | int | required | 图片左上角所在行0-based |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B` |
| `--size-width` | int | required | 图片宽度(像素) |
@@ -78,8 +78,8 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--float-image-id` | string | required | 目标图片 id |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png` |
| `--image-token` | string | optional | 可选图片 file_token`--image-uri` 互斥,二者均省略时保留原图。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | optional | 可选图片 URI上传链路返回的句柄非表内对象 reference_id`--image-token` 互斥,二者均省略时保留原图;系统自动转换为 file_token |
| `--image-token` | string | xor | 图片 file_token`--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | xor | 图片 reference_id`--image-token` 二选一);图片上传链路返回的 reference_id |
| `--position-row` | int | required | 图片左上角所在行0-based |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B` |
| `--size-width` | int | required | 图片宽度(像素) |

View File

@@ -77,7 +77,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--index` | int | optional | 插入位置0-based省略时附加到末尾 |
| `--row-count` | int | optional | 初始行数(默认 200上限 50000 |
| `--col-count` | int | optional | 初始列数(默认 20上限 200 |
| `--type` | string | optional | 新子表类型sheet电子表格;默认 sheet。可选值`sheet` |
| `--type` | string | optional | 新子表类型sheet电子表格\| bitable多维表格默认 sheet。bitable 只建空表,内容编辑改用 lark-base 命令(可选值:`sheet` / `bitable` |
### `+sheet-delete`
@@ -364,8 +364,17 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
--title "汇总" --index 0
```
新建一张**多维表格bitable子表**:加 `--type bitable`(默认 `sheet`,即普通电子表格子表)。
```bash
lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
--title "任务表" --type bitable
```
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
> 💡 `--type bitable` 只建一张**空的多维表格子表**(默认表 + 网格视图 + 默认字段)。它的内容编辑(字段、记录、视图)走 `lark-cli base`:先用 `+workbook-info` 拿到该子表的 `bitable_app_token` + `bitable_table_id`,再用 `lark-cli base +record-list` / `+record-create` 等操作sheets 侧的网格类命令(`+cells-get` / `+cells-set` 等)对 bitable 子表会被拒。
### `+sheet-delete`
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。