diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 17de7bdda..63d03df24 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -94,6 +94,28 @@ func TestDryRunFieldOps(t *testing.T) { assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open") } +func TestDryRunFieldUpdateAutoNumberReformat(t *testing.T) { + ctx := context.Background() + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "field-id": "fld_auto", + "json": `{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"},{"type":"created_time","date_format":"yyyyMMdd"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`, + }, + map[string]bool{"reformat-existing-records": true}, + nil, + ) + assertDryRunContains( + t, + dryRunFieldUpdate(ctx, rt), + "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_auto", + "PUT /open-apis/bitable/v1/apps/app_x/tables/tbl_1/fields/fld_auto", + `"reformat_existing_records":true`, + `"value":"ORD-"`, + ) +} + func TestDryRunRecordOps(t *testing.T) { ctx := context.Background() @@ -136,6 +158,13 @@ func TestDryRunRecordOps(t *testing.T) { ) assertDryRunContains(t, dryRunRecordList(ctx, commaFieldRT), "limit=1", "offset=0", "field_id=A%2CB", "field_id=C") + pageSizeRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + nil, + map[string]int{"page-size": 1}, + ) + assertDryRunContains(t, dryRunRecordList(ctx, pageSizeRT), "limit=1", "offset=0") + searchRT := newBaseTestRuntime( map[string]string{ "base-token": "app_x", diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 79bd12ac4..f811a9c37 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -824,6 +824,280 @@ func TestBaseFieldExecuteUpdate(t *testing.T) { } } +func TestBaseFieldExecuteUpdateNoOpReturnsStructuredSuccess(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 800070003, + "msg": "failed", + "data": map[string]interface{}{ + "error": map[string]interface{}{ + "type": "api_error", + "message": "no operation produced", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, + }, + }) + + if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["updated"] != false { + t.Fatalf("updated = %#v, want false", data["updated"]) + } + if data["noop"] != true { + t.Fatalf("noop = %#v, want true", data["noop"]) + } + field, _ := data["field"].(map[string]interface{}) + if got := common.GetString(field, "id"); got != "fld_x" { + t.Fatalf("field.id = %q, want %q", got, "fld_x") + } +} + +func TestBaseFieldExecuteUpdateNoOpKeepsErrorWhenReadbackDoesNotMatch(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 800070003, + "msg": "failed", + "data": map[string]interface{}{ + "error": map[string]interface{}{ + "type": "api_error", + "message": "no operation produced", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "text"}, + }, + }) + + err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout) + if err == nil { + t.Fatal("expected mismatch readback to preserve the original no-op error") + } + if !strings.Contains(err.Error(), "no operation produced") { + t.Fatalf("err=%v, want no-op error", err) + } +} + +func TestBaseFieldExecuteUpdateAutoNumberReformatNoOpStillErrors(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 800070003, + "msg": "failed", + "data": map[string]interface{}{ + "error": map[string]interface{}{ + "type": "api_error", + "message": "no operation produced", + }, + }, + }, + }) + + args := []string{ + "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_auto", + "--json", `{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"}]}}`, + "--reformat-existing-records", + "--yes", + } + err := runShortcut(t, BaseFieldUpdate, args, factory, stdout) + if err == nil { + t.Fatal("expected no-op auto-number reformat update to keep returning an error") + } + if !strings.Contains(err.Error(), "no operation produced") { + t.Fatalf("err=%v, want no-op error", err) + } +} + +func TestBaseFieldExecuteUpdateAutoNumberReformat(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + updateStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_auto", "name": "自动编号", "type": "auto_number"}, + }, + } + reformatStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/bitable/v1/apps/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"field_id": "fld_auto", "field_name": "自动编号", "type": 1005}, + }, + } + readStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "fld_auto", + "name": "自动编号", + "type": "auto_number", + "style": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"type": "text", "text": "ORD-"}, + map[string]interface{}{"type": "created_time", "date_format": "yyyyMMdd"}, + map[string]interface{}{"type": "text", "text": "-"}, + map[string]interface{}{"type": "incremental_number", "length": 4}, + }, + }, + }, + }, + } + readBackStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "fld_auto", + "name": "自动编号", + "type": "auto_number", + "style": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"type": "text", "text": "ORD-"}, + map[string]interface{}{"type": "created_time", "date_format": "yyyyMMdd"}, + map[string]interface{}{"type": "text", "text": "-"}, + map[string]interface{}{"type": "incremental_number", "length": 4}, + }, + }, + }, + }, + } + reg.Register(updateStub) + reg.Register(reformatStub) + reg.Register(readStub) + reg.Register(readBackStub) + + args := []string{ + "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_auto", + "--json", `{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"},{"type":"created_time","date_format":"yyyyMMdd"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`, + "--reformat-existing-records", + "--yes", + } + if err := runShortcut(t, BaseFieldUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["reformatted_existing_records"] != true { + t.Fatalf("reformatted_existing_records = %#v, want true", data["reformatted_existing_records"]) + } + updateBody := decodeCapturedJSONBody(t, updateStub) + if updateBody["type"] != "auto_number" { + t.Fatalf("update body type = %#v", updateBody["type"]) + } + reformatBody := decodeCapturedJSONBody(t, reformatStub) + property, _ := reformatBody["property"].(map[string]interface{}) + autoSerial, _ := property["auto_serial"].(map[string]interface{}) + if autoSerial["reformat_existing_records"] != true { + t.Fatalf("auto_serial.reformat_existing_records = %#v", autoSerial["reformat_existing_records"]) + } + options, _ := autoSerial["options"].([]interface{}) + if len(options) != 4 { + t.Fatalf("options len = %d, want 4", len(options)) + } +} + +func TestBaseFieldExecuteUpdateAutoNumberLegacyReformatShape(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + updateStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_auto", "name": "自动编号", "type": "auto_number"}, + }, + } + reformatStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/bitable/v1/apps/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"field_id": "fld_auto", "field_name": "自动编号", "type": 1005}, + }, + } + readStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_auto", "name": "自动编号", "type": "auto_number"}, + }, + } + readBackStub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_auto", "name": "自动编号", "type": "auto_number"}, + }, + } + reg.Register(updateStub) + reg.Register(reformatStub) + reg.Register(readStub) + reg.Register(readBackStub) + + args := []string{ + "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_auto", + "--json", `{"field_name":"自动编号","type":1005,"property":{"auto_serial":{"type":"custom","reformat_existing_records":true,"options":[{"type":"fixed_text","value":"HX-"},{"type":"created_time","value":"yyyyMMdd"},{"type":"fixed_text","value":"-N-"},{"type":"system_number","value":"4"}]}}}`, + "--yes", + } + if err := runShortcut(t, BaseFieldUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + updateBody := decodeCapturedJSONBody(t, updateStub) + if updateBody["type"] != "auto_number" { + t.Fatalf("update body type = %#v", updateBody["type"]) + } + style, _ := updateBody["style"].(map[string]interface{}) + rules, _ := style["rules"].([]interface{}) + if len(rules) != 4 { + t.Fatalf("rules len = %d, want 4", len(rules)) + } + reformatBody := decodeCapturedJSONBody(t, reformatStub) + property, _ := reformatBody["property"].(map[string]interface{}) + autoSerial, _ := property["auto_serial"].(map[string]interface{}) + if autoSerial["reformat_existing_records"] != true { + t.Fatalf("auto_serial.reformat_existing_records = %#v", autoSerial["reformat_existing_records"]) + } +} + func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { tests := []struct { name string @@ -1084,6 +1358,39 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("get hidden reverse field hint", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_reverse", + Body: map[string]interface{}{ + "code": 800030201, + "msg": "not_found", + "error": map[string]interface{}{ + "message": "not_found", + "hint": "List fields in the current table, then retry with the exact field id or name that belongs to this table.", + "path": "/fields/:field_id", + "type": "not_found", + "table": map[string]interface{}{"id": "tbl_x", "name": "Data"}, + "value": "fld_reverse", + }, + }, + }) + + err := runShortcut(t, BaseFieldGet, []string{"+field-get", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_reverse"}, factory, stdout) + if err == nil { + t.Fatal("expected error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected output.ExitError, got %T: %v", err, err) + } + if got := exitErr.Detail.Hint; !strings.Contains(got, "linked table's auto-created reverse field") || !strings.Contains(got, "+field-list / +field-get") { + t.Fatalf("hint=%q", got) + } + }) + t.Run("create", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -1277,6 +1584,38 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) + t.Run("list page-size alias", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Name"}, + "field_id_list": []interface{}{"fld_name"}, + "record_id_list": []interface{}{"rec_alias"}, + "data": []interface{}{[]interface{}{"Alias"}}, + "total": 1, + }, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--page-size", "1", "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alias"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list rejects limit and page-size together", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--page-size", "1", "--format", "json"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--limit and --page-size are mutually exclusive") { + t.Fatalf("err=%v", err) + } + }) + t.Run("list markdown format", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 4dd2769c4..eddb9a479 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -461,6 +461,44 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) { } } +func TestBaseViewListTipsGuideRouting(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseViewList.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range []string{ + "`--table-id` accepts a table ID or the table name directly; there is no `--table-name` flag.", + "Use +view-list only when the table is known but the view name is ambiguous", + "Grid row height, display density, and TableManager layout properties are not supported", + } { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } +} + +func TestBaseViewSetVisibleFieldsTipsGuideRouting(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseViewSetVisibleFields.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + if got := cmd.Short; got != "Set view visible fields order and visibility" { + t.Fatalf("short=%q", got) + } + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range []string{ + "Use this command when the user wants to reorder visible fields or hide columns", + "there is no generic +view-update shortcut", + "primary field may be forced to the first position by the API", + } { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } +} + func TestBaseDashboardHelpGuidesAgents(t *testing.T) { tests := []struct { name string @@ -804,6 +842,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) { "does not auto-upsert by business key", "use +field-list to confirm real writable fields", "do not write system fields, formula, lookup, or attachment fields", + "Link CellValue writes associations between existing records", + "not a native child-record or hierarchy feature", "CellValue happy path: text/phone/url", "select -> \"Todo\"", "multi-select -> [\"Tag A\",\"Tag B\"]", @@ -864,6 +904,26 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) { } } +func TestBaseFieldGetHelpGuidesAgents(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseFieldGet.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + wantTips := []string{ + `lark-cli base +field-get --base-token --table-id --field-id "Status"`, + "field-id accepts a field ID (fld...) or the field name from the current table.", + "Returns full field configuration; use it as the baseline before +field-update.", + "bidirectional reverse-field ID returns not_found", + "linked table's auto-created reverse field", + } + for _, want := range wantTips { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } +} + func TestBaseBlockHelpGuidesAgents(t *testing.T) { tests := []struct { name string @@ -945,6 +1005,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) { help := cmd.Flags().FlagUsages() wantHelp := []string{ "complete field definition JSON object; update uses full PUT semantics, not a patch", + "for auto_number updates only: regenerate existing values with the updated numbering rules", } for _, want := range wantHelp { if !strings.Contains(help, want) { @@ -956,8 +1017,11 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) { wantTips := []string{ `lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`, `"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`, + `"type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"}`, "full field-definition PUT semantics", "Read the current field first with +field-get", + "--reformat-existing-records", + "raw lark-cli api writes", "Type conversion is allowlist-based", "web UI", "Formula and lookup updates require reading the corresponding guide first.", @@ -1164,6 +1228,20 @@ func TestBaseRecordValidate(t *testing.T) { )); err != nil { t.Fatalf("record list filter-json validate err=%v", err) } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1"}, + nil, + map[string]int{"page-size": 1}, + )); err != nil { + t.Fatalf("record list page-size alias validate err=%v", err) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1"}, + nil, + map[string]int{"limit": 1, "page-size": 1}, + )); err == nil || !strings.Contains(err.Error(), "--limit and --page-size are mutually exclusive") { + t.Fatalf("err=%v", err) + } if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`}, nil, diff --git a/shortcuts/base/field_get.go b/shortcuts/base/field_get.go index a272b54c5..7ef9d4ec4 100644 --- a/shortcuts/base/field_get.go +++ b/shortcuts/base/field_get.go @@ -21,6 +21,7 @@ var BaseFieldGet = common.Shortcut{ `Example: lark-cli base +field-get --base-token --table-id --field-id "Status"`, "field-id accepts a field ID (fld...) or the field name from the current table.", "Returns full field configuration; use it as the baseline before +field-update.", + "If a bidirectional reverse-field ID returns not_found here, it belongs to the linked table's auto-created reverse field; switch to that table or keep using the forward link field in the current table.", }, DryRun: dryRunFieldGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 39d6f0d63..d9e5d7f8e 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -5,8 +5,13 @@ package base import ( "context" + "errors" + "fmt" + "reflect" + "strconv" "strings" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,12 +49,23 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) body, _ := parseJSONObject(pc, runtime.Str("json"), "json") - return common.NewDryRunAPI(). + normalized, reformatExisting, err := normalizeFieldUpdateBody(runtime, body) + if err != nil || normalized == nil { + normalized = body + reformatExisting = false + } + dr := common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). - Body(body). + Body(normalized). Set("base_token", runtime.Str("base-token")). Set("table_id", baseTableID(runtime)). Set("field_id", runtime.Str("field-id")) + if reformatExisting { + if legacyBody, legacyErr := buildAutoNumberReformatBody(normalized, dryRunFieldName(runtime, normalized)); legacyErr == nil { + dr.PUT("/open-apis/bitable/v1/apps/:base_token/tables/:table_id/fields/:field_id").Body(legacyBody) + } + } + return dr } func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -107,7 +123,11 @@ func validateFieldUpdate(runtime *common.RuntimeContext) error { if err != nil { return err } - return validateFormulaLookupGuideAck(runtime, "+field-update", body) + normalized, _, err := normalizeFieldUpdateBody(runtime, body) + if err != nil { + return err + } + return validateFormulaLookupGuideAck(runtime, "+field-update", normalized) } func executeFieldList(runtime *common.RuntimeContext) error { @@ -133,12 +153,45 @@ func executeFieldGet(runtime *common.RuntimeContext) error { fieldRef := runtime.Str("field-id") data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) if err != nil { - return err + return enrichFieldGetNotFoundHint(fieldRef, err) } runtime.Out(map[string]interface{}{"field": data}, nil) return nil } +func enrichFieldGetNotFoundHint(fieldRef string, err error) error { + fieldRef = strings.TrimSpace(fieldRef) + if !strings.HasPrefix(fieldRef, "fld") { + return err + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr == nil || exitErr.Detail == nil { + return err + } + + detail := exitErr.Detail + if detail.Code != 800030201 && detail.Type != "not_found" && !strings.EqualFold(strings.TrimSpace(detail.Message), "not_found") { + return err + } + + detailMap, _ := detail.Detail.(map[string]interface{}) + if path := strings.TrimSpace(common.GetString(detailMap, "path")); path != "" && path != "/fields/:field_id" { + return err + } + + extraHint := "If this field ID came from a bidirectional link, it may be the linked table's auto-created reverse field rather than a field in the current table. Use the forward link field here, or switch to the linked table and run +field-list / +field-get there." + if strings.Contains(detail.Hint, "auto-created reverse field") { + return err + } + if strings.TrimSpace(detail.Hint) == "" { + detail.Hint = extraHint + return err + } + detail.Hint = strings.TrimSpace(detail.Hint + " " + extraHint) + return err +} + func executeFieldCreate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) body, err := parseJSONObject(pc, runtime.Str("json"), "json") @@ -161,15 +214,97 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error { if err != nil { return err } + normalized, reformatExisting, err := normalizeFieldUpdateBody(runtime, body) + if err != nil { + return err + } fieldRef := runtime.Str("field-id") - data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, body) + data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, normalized) + if err != nil { + if !reformatExisting && isFieldUpdateNoopError(err) { + fieldData, readErr := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) + if readErr != nil || !fieldUpdateSubsetMatches(fieldData, normalized) { + return err + } + runtime.Out(map[string]interface{}{"field": fieldData, "updated": false, "noop": true}, nil) + return nil + } + return err + } + if !reformatExisting { + runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil) + return nil + } + + tableIDResolved, err := resolveLegacyFieldUpdateTableID(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + fieldIDResolved, fieldNameResolved, err := resolveLegacyFieldUpdateField(runtime, baseToken, tableIDResolved, fieldRef) + if err != nil { + return err + } + legacyBody, err := buildAutoNumberReformatBody(normalized, fieldNameResolved) + if err != nil { + return err + } + if _, err := bitableV1Call(runtime, "PUT", bitableV1Path("apps", baseToken, "tables", tableIDResolved, "fields", fieldIDResolved), nil, legacyBody); err != nil { + return err + } + fieldData, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDResolved, "fields", fieldIDResolved), nil, nil) if err != nil { return err } - runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil) + runtime.Out(map[string]interface{}{"field": fieldData, "updated": true, "reformatted_existing_records": true}, nil) return nil } +func isFieldUpdateNoopError(err error) bool { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr == nil || exitErr.Detail == nil { + return false + } + if exitErr.Detail.Code != 800070003 { + return false + } + msg := strings.TrimSpace(exitErr.Detail.Message) + return msg == "" || strings.EqualFold(msg, "no operation produced") +} + +func fieldUpdateSubsetMatches(actual map[string]interface{}, desired map[string]interface{}) bool { + return fieldUpdateValueMatches(actual, desired) +} + +func fieldUpdateValueMatches(actual interface{}, desired interface{}) bool { + switch want := desired.(type) { + case map[string]interface{}: + got, ok := actual.(map[string]interface{}) + if !ok { + return false + } + for key, wantValue := range want { + gotValue, exists := got[key] + if !exists || !fieldUpdateValueMatches(gotValue, wantValue) { + return false + } + } + return true + case []interface{}: + got, ok := actual.([]interface{}) + if !ok || len(got) != len(want) { + return false + } + for i := range want { + if !fieldUpdateValueMatches(got[i], want[i]) { + return false + } + } + return true + default: + return reflect.DeepEqual(actual, desired) + } +} + func executeFieldDelete(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) @@ -212,3 +347,239 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error { }, nil) return nil } + +func normalizeFieldUpdateBody(runtime *common.RuntimeContext, body map[string]interface{}) (map[string]interface{}, bool, error) { + if body == nil { + return nil, false, nil + } + normalized := cloneMap(body) + reformatExisting := runtime.Bool("reformat-existing-records") + isAutoNumber := false + + switch strings.ToLower(strings.TrimSpace(common.GetString(normalized, "type"))) { + case "autonumber", "auto-number", "auto_number": + normalized["type"] = "auto_number" + isAutoNumber = true + } + if toInt(normalized["type"]) == 1005 { + normalized["type"] = "auto_number" + isAutoNumber = true + } + + if fieldName := strings.TrimSpace(common.GetString(normalized, "field_name")); fieldName != "" && strings.TrimSpace(common.GetString(normalized, "name")) == "" { + normalized["name"] = fieldName + } + delete(normalized, "field_name") + + style, _ := normalized["style"].(map[string]interface{}) + if style != nil { + style = cloneMap(style) + if flag, ok := style["reformat_existing_records"].(bool); ok { + reformatExisting = reformatExisting || flag + delete(style, "reformat_existing_records") + } + if len(style) == 0 { + delete(normalized, "style") + } else { + normalized["style"] = style + } + } + + if property, ok := normalized["property"].(map[string]interface{}); ok && property != nil { + if autoSerial, ok := property["auto_serial"].(map[string]interface{}); ok && autoSerial != nil { + isAutoNumber = true + normalized["type"] = "auto_number" + if flag, ok := autoSerial["reformat_existing_records"].(bool); ok { + reformatExisting = reformatExisting || flag + } + if style == nil { + style = map[string]interface{}{} + } + if _, exists := style["rules"]; !exists { + if options, ok := autoSerial["options"].([]interface{}); ok && len(options) > 0 { + rules, err := legacyAutoNumberOptionsToRules(options) + if err != nil { + return nil, false, err + } + style["rules"] = rules + } + } + if len(style) == 0 { + delete(normalized, "style") + } else { + normalized["style"] = style + } + delete(normalized, "property") + } + } + + if reformatExisting && !isAutoNumber { + currentType := strings.TrimSpace(common.GetString(normalized, "type")) + if currentType == "" { + currentType = strings.TrimSpace(fmt.Sprintf("%v", body["type"])) + } + if currentType == "" || currentType == "" { + currentType = "unset" + } + return nil, false, common.FlagErrorf("--reformat-existing-records is only supported when --json.type is %q; current type is %q", "auto_number", currentType) + } + + return normalized, reformatExisting, nil +} + +func legacyAutoNumberOptionsToRules(options []interface{}) ([]interface{}, error) { + rules := make([]interface{}, 0, len(options)) + for i, item := range options { + option, ok := item.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("auto_number legacy option %d must be an object", i+1) + } + switch strings.TrimSpace(common.GetString(option, "type")) { + case "fixed_text": + rules = append(rules, map[string]interface{}{"type": "text", "text": common.GetString(option, "value")}) + case "created_time": + dateFormat := common.GetString(option, "value") + if dateFormat == "" { + dateFormat = "yyyyMMdd" + } + rules = append(rules, map[string]interface{}{"type": "created_time", "date_format": dateFormat}) + case "system_number": + length := toInt(option["value"]) + if length <= 0 { + length = 3 + } + rules = append(rules, map[string]interface{}{"type": "incremental_number", "length": length}) + default: + return nil, common.FlagErrorf("unsupported auto_number legacy option type %q; use fixed_text, created_time, or system_number", common.GetString(option, "type")) + } + } + return rules, nil +} + +func autoNumberRulesToLegacyOptions(rules []interface{}) ([]interface{}, error) { + options := make([]interface{}, 0, len(rules)) + for i, item := range rules { + rule, ok := item.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("auto_number style.rules[%d] must be an object", i) + } + switch strings.TrimSpace(common.GetString(rule, "type")) { + case "text": + options = append(options, map[string]interface{}{"type": "fixed_text", "value": common.GetString(rule, "text")}) + case "created_time": + dateFormat := common.GetString(rule, "date_format") + if dateFormat == "" { + dateFormat = "yyyyMMdd" + } + options = append(options, map[string]interface{}{"type": "created_time", "value": dateFormat}) + case "incremental_number": + length := toInt(rule["length"]) + if length <= 0 { + length = 3 + } + options = append(options, map[string]interface{}{"type": "system_number", "value": strconv.Itoa(length)}) + default: + return nil, common.FlagErrorf("unsupported auto_number rule type %q; use text, created_time, or incremental_number", common.GetString(rule, "type")) + } + } + return options, nil +} + +func autoNumberRulesFromBody(body map[string]interface{}) ([]interface{}, error) { + if style, ok := body["style"].(map[string]interface{}); ok && style != nil { + if rawRules, exists := style["rules"]; exists { + rules, ok := rawRules.([]interface{}) + if !ok { + return nil, common.FlagErrorf("auto_number style.rules must be a JSON array when using --reformat-existing-records") + } + cloned, _ := cloneValue(rules).([]interface{}) + return cloned, nil + } + } + spec, err := resolveFieldTypeSpec("auto_number") + if err != nil { + return nil, err + } + style, _ := spec.Extra["style"].(map[string]interface{}) + rules, _ := style["rules"].([]interface{}) + if len(rules) == 0 { + return nil, common.FlagErrorf("default auto_number style rules are unavailable") + } + cloned, _ := cloneValue(rules).([]interface{}) + return cloned, nil +} + +func buildAutoNumberReformatBody(body map[string]interface{}, fieldName string) (map[string]interface{}, error) { + fieldName = strings.TrimSpace(fieldName) + if fieldName == "" { + return nil, common.FlagErrorf("auto_number reformat needs a field name; include --json.name or read the current field first with +field-get") + } + rules, err := autoNumberRulesFromBody(body) + if err != nil { + return nil, err + } + options, err := autoNumberRulesToLegacyOptions(rules) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "field_name": fieldName, + "type": 1005, + "property": map[string]interface{}{ + "auto_serial": map[string]interface{}{ + "type": "custom", + "reformat_existing_records": true, + "options": options, + }, + }, + }, nil +} + +func dryRunFieldName(runtime *common.RuntimeContext, body map[string]interface{}) string { + if body != nil { + if name := strings.TrimSpace(common.GetString(body, "name")); name != "" { + return name + } + } + fieldRef := strings.TrimSpace(runtime.Str("field-id")) + if fieldRef != "" && !strings.HasPrefix(fieldRef, "fld") { + return fieldRef + } + return "" +} + +func resolveLegacyFieldUpdateTableID(runtime *common.RuntimeContext, baseToken string, tableRef string) (string, error) { + if strings.HasPrefix(strings.TrimSpace(tableRef), "tbl") { + return tableRef, nil + } + table, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableRef), nil, nil) + if err != nil { + return "", err + } + if tableID := strings.TrimSpace(common.GetString(table, "id")); tableID != "" { + return tableID, nil + } + if tableID := strings.TrimSpace(common.GetString(table, "table_id")); tableID != "" { + return tableID, nil + } + return "", output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("resolved table %q but the response did not include a table id", tableRef), "Retry with the table ID from +table-list or +table-get.") +} + +func resolveLegacyFieldUpdateField(runtime *common.RuntimeContext, baseToken string, tableRef string, fieldRef string) (string, string, error) { + field, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableRef, "fields", fieldRef), nil, nil) + if err != nil { + return "", "", err + } + fieldID := strings.TrimSpace(common.GetString(field, "id")) + if fieldID == "" { + fieldID = strings.TrimSpace(common.GetString(field, "field_id")) + } + fieldName := strings.TrimSpace(common.GetString(field, "name")) + if fieldName == "" { + fieldName = strings.TrimSpace(common.GetString(field, "field_name")) + } + if fieldID == "" { + return "", "", output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("resolved field %q but the response did not include a field id", fieldRef), "Retry with the field ID from +field-list or +field-get.") + } + return fieldID, fieldName, nil +} diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go index 177ad7e9a..e3b599402 100644 --- a/shortcuts/base/field_update.go +++ b/shortcuts/base/field_update.go @@ -21,13 +21,16 @@ var BaseFieldUpdate = common.Shortcut{ tableRefFlag(true), fieldRefFlag(true), {Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true}, + {Name: "reformat-existing-records", Type: "bool", Desc: "for auto_number updates only: regenerate existing values with the updated numbering rules"}, {Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true}, }, Tips: []string{ baseHighRiskYesTip, `Example text: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`, `Example select: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`, + `Example auto_number reflow: lark-cli base +field-update --base-token --table-id --field-id "自动编号" --json '{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"},{"type":"created_time","date_format":"yyyyMMdd"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --reformat-existing-records --yes`, "Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.", + "Auto-number updates that must apply new rules to existing rows stay on +field-update; add --reformat-existing-records instead of switching to raw lark-cli api writes.", "Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.", "Formula and lookup updates require reading the corresponding guide first.", "Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.", diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index b2dbcc05d..62a4afff1 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -22,8 +22,9 @@ import ( ) const ( - batchSize = 500 - baseV3ServicePath = "/open-apis/base/v3" + batchSize = 500 + baseV3ServicePath = "/open-apis/base/v3" + bitableV1ServicePath = "/open-apis/bitable/v1" ) type fieldTypeSpec struct { @@ -375,6 +376,14 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{ } func baseV3Path(parts ...string) string { + return servicePath(baseV3ServicePath, parts...) +} + +func bitableV1Path(parts ...string) string { + return servicePath(bitableV1ServicePath, parts...) +} + +func servicePath(prefix string, parts ...string) string { clean := make([]string, 0, len(parts)) for _, part := range parts { part = strings.Trim(part, "/") @@ -382,7 +391,7 @@ func baseV3Path(parts ...string) string { clean = append(clean, url.PathEscape(part)) } } - return baseV3ServicePath + "/" + strings.Join(clean, "/") + return prefix + "/" + strings.Join(clean, "/") } func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { @@ -504,6 +513,11 @@ func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[ return handleBaseAPIResult(result, err, "API call failed") } +func bitableV1Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := baseV3Raw(runtime, method, path, params, data) + return handleBaseAPIResult(result, err, "API call failed") +} + func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) { result, err := baseV3Raw(runtime, method, path, params, data) return handleBaseAPIResultAny(result, err, "API call failed") diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go index abcea7e9f..f822e6f8a 100644 --- a/shortcuts/base/record_upsert.go +++ b/shortcuts/base/record_upsert.go @@ -26,6 +26,7 @@ var BaseRecordUpsert = common.Shortcut{ "Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.", "Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.", "Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.", + "Link CellValue writes associations between existing records. It is not a native child-record or hierarchy feature; only use it when the user explicitly wants a link relation in a real link field.", "Use the record-upsert guide for command limits and edge cases.", }, recordCellValueHappyPathTips...), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/view_list.go b/shortcuts/base/view_list.go index 625310c86..3ca3f3406 100644 --- a/shortcuts/base/view_list.go +++ b/shortcuts/base/view_list.go @@ -37,6 +37,11 @@ var BaseViewList = common.Shortcut{ } return nil }, + Tips: []string{ + "`--table-id` accepts a table ID or the table name directly; there is no `--table-name` flag.", + "Use +view-list only when the table is known but the view name is ambiguous, or when a direct view command returned not_found and you need disambiguation.", + "Grid row height, display density, and TableManager layout properties are not supported by current +view-* shortcuts; stop and report unsupported instead of searching raw or legacy APIs.", + }, DryRun: dryRunViewList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewList(runtime) diff --git a/shortcuts/base/view_set_visible_fields.go b/shortcuts/base/view_set_visible_fields.go index e1b54b121..8d727cd57 100644 --- a/shortcuts/base/view_set_visible_fields.go +++ b/shortcuts/base/view_set_visible_fields.go @@ -12,7 +12,7 @@ import ( var BaseViewSetVisibleFields = common.Shortcut{ Service: "base", Command: "+view-set-visible-fields", - Description: "Set view visible fields", + Description: "Set view visible fields order and visibility", Risk: "write", Scopes: []string{"base:view:write_only"}, AuthTypes: authTypes(), @@ -23,6 +23,7 @@ var BaseViewSetVisibleFields = common.Shortcut{ {Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true}, }, Tips: []string{ + "Use this command when the user wants to reorder visible fields or hide columns; there is no generic +view-update shortcut.", "Supported view types: grid, kanban, gallery, calendar, gantt.", "Use a JSON object, not a bare array; primary field may be forced to the first position by the API.", "visible_fields controls both visibility and order; include every field that should remain visible.", diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 3399146dc..86eb7fe34 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -42,6 +42,21 @@ metadata: - 文档嵌入 Base 标签:直接读取 `` / `` 的 `token` 作为 `--base-token`,`table-id` 作为 `--table-id`,`view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`。 - 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base;用户要新建时才用 `+base-create`。 +> [!CAUTION] +> 禁止使用 `lark-cli api` 对 Base 做写操作,也不要为了绕过 shortcut 能力缺口去调用 legacy / undocumented Base API。 +> +> - Forbidden: `lark-cli api PUT "/open-apis/base/v3/bases/.../fields/..." --data ...` +> - Forbidden: `lark-cli api PUT "/open-apis/bitable/v1/apps/.../fields/..." --data ...` +> +> 自动编号字段要“将修改用于已有编号”时,仍然停留在 `lark-cli base +field-update --reformat-existing-records --yes` 这条路径。 + +> [!CAUTION] +> `子记录` / `层级记录` 目前不是 `lark-cli base` 的原生能力,不要把自关联或双向关联 `link` 默认当作“子记录已完成”。 +> +> - 用户只说“新增子记录 / 层级记录”时,先明确说明当前 CLI 没有原生命令,不要继续查 schema、仓库或试探自关联写入。 +> - 只有用户明确接受“关联记录”语义,且表里已有真实 `link` 字段时,才用 `+record-upsert` / `+record-batch-*` 写 link CellValue。 +> - `bidirectional` 自动创建的反向字段属于被关联表;如果拿这个反向字段 ID 在当前表执行 `+field-get` 返回 `not_found`,切到被关联表读结构,不要继续在当前表试探。 + ## 快速路由 | 用户目标 | 优先命令 | 何时读 reference | @@ -52,12 +67,12 @@ metadata: | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | -| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | +| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);自动编号要把新规则应用到已有记录时直接加 `--reformat-existing-records --yes`;公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 | | 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 | -| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 | +| 管理视图 | `+view-get/*`、`+view-set-*`、`+view-rename` | 字段显隐/顺序走 `+view-set-visible-fields`;筛选走 `+view-set-filter` 并读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);分组/排序走 `+view-set-group` / `+view-set-sort`;不存在通用 `+view-update`,其余配置先 get 现状,再按对应 set 命令更新 | | 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) | | 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` | | Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` | @@ -118,8 +133,32 @@ metadata: - `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 - 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。 - `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 +- 视图字段显隐和字段顺序统一走 `+view-set-visible-fields`;不要猜 `+view-update` 这类通用视图写命令。 +- 视图相关命令中的 `--table-id` 可直接传表 ID 或表名;没有单独的 `--table-name` flag。 - 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 +> [!CAUTION] +> Base `+view-*` 当前不支持 grid 行高 / display density / TableManager 布局属性。 +> +> - 用户要改“行高 / 密度 / TableManager 布局”时,直接说明当前 shortcut 不支持,不要继续 grep 仓库、猜 legacy API 或试探 undocumented view PATCH。 +> - 字段显隐/字段顺序、筛选、分组、排序、timebar、card 等仍走现有 `+view-set-*` 命令。 + +## Token 与链接 + +| 输入类型 | 含义 / 正确处理方式 | +|---|---| +| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` | +| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` | +| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id`;`blk` 开头是 dashboard ID;`wkf` 开头是 workflow ID | +| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 | +| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token,走 `+form-detail` / `+form-submit --share-token ` | +| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | +| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | +| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token --table-id --record-ids ` | +| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 | + +`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。 + ## Dashboard / Workflow / Role - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index bfe77c3a3..d7850dcf2 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -310,7 +310,9 @@ - `link` 字段的单元格表示“当前记录关联到的对侧表记录集合” - `bidirectional` 默认 `false` - `bidirectional=true` 时,会在被关联表自动创建一个反向关联字段。任一侧记录的关联关系发生变更时,另一侧对应记录会自动同步更新 +- 自动创建的反向关联字段属于被关联表;如果拿这个反向字段 ID 在当前表执行 `+field-get`,会得到 `not_found`。要读取它,切到被关联表;只想维护关系时,继续操作当前表里的正向 `link` 字段 - `bidirectional_link_field_name` 仅在 `bidirectional=true` 时使用 +- `link` / `bidirectional` 建模的是“关联记录”,不是 CLI 的原生“子记录/层级记录”能力;不要因为用户说“子记录”就默认改走自关联写入 - 关联字段筛选:这个功能在 Base 前端支持,属于 UI-only 属性,OpenAPI 里不支持,CLI 不能读取、创建或更新;不要根据接口返回缺失判断未配置 ```json @@ -384,6 +386,8 @@ 自动编号字段;不写 `style.rules` 时使用默认规则:`NO.001`。 +如果这次更新还要把新规则应用到已有记录,继续使用 `lark-cli base +field-update`,并额外加命令 flag `--reformat-existing-records --yes`。`reformat_existing_records` 不是字段 JSON 的一部分,不要把它写进 `style` 或旧的 `property.auto_serial` 结构里。 + 最小写法: ```json diff --git a/skills/lark-base/references/lark-base-field-update.md b/skills/lark-base/references/lark-base-field-update.md index 3cd35cdf2..d0095d932 100644 --- a/skills/lark-base/references/lark-base-field-update.md +++ b/skills/lark-base/references/lark-base-field-update.md @@ -20,6 +20,14 @@ lark-cli base +field-update \ --field-id \ --json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' \ --yes + +lark-cli base +field-update \ + --base-token \ + --table-id \ + --field-id "自动编号" \ + --json '{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"},{"type":"created_time","date_format":"yyyyMMdd"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \ + --reformat-existing-records \ + --yes ``` ## 参数 @@ -30,6 +38,7 @@ lark-cli base +field-update \ | `--table-id ` | 是 | 表 ID 或表名 | | `--field-id ` | 是 | 字段 ID 或字段名 | | `--json ` | 是 | 字段属性 JSON 对象 | +| `--reformat-existing-records` | 否 | 仅 `auto_number` 更新可用:按新的编号规则重排已有记录 | | `--yes` | 是 | 确认执行高风险字段更新 | > 这是**高风险写入操作**。`+field-update` 使用 `PUT` 全量字段定义语义;改变字段类型或关键配置可能影响整列已有数据的解释、展示或可用性。CLI 层要求显式传 `--yes`;如果用户已经明确目标和期望更新,可直接执行并带上 `--yes`。 @@ -46,6 +55,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id - `--json` 必须是 **JSON 对象**,顶层直接传字段定义。 - 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name`、`type`,并补齐该类型所需关键配置。 +- “将修改用于已有编号”是命令 flag,不是 JSON 字段:对自动编号使用 `--reformat-existing-records`,不要把 `reformat_existing_records` 塞进 `style` 或 `property.auto_serial`。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。 - `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。 - `link` 更新限制: @@ -88,6 +98,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id 1. 建议先用 `+field-get` 拉现状,再做最小化修改。 2. `formula/lookup` 类型更新前先阅读对应指南。 3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。 +4. 自动编号字段要把新规则应用到已有记录时,继续用 `+field-update`,并显式加 `--reformat-existing-records --yes`;不要改走 `lark-cli api`。 ## 字段类型变更规则 diff --git a/skills/lark-base/references/lark-base-record-upsert.md b/skills/lark-base/references/lark-base-record-upsert.md index 79d34a411..8a939ce67 100644 --- a/skills/lark-base/references/lark-base-record-upsert.md +++ b/skills/lark-base/references/lark-base-record-upsert.md @@ -56,6 +56,7 @@ lark-cli base +record-upsert --base-token --table-id --r - 有 `--record-id` 就一定更新;不传就一定创建,不会自动查重或按业务键 upsert。 - select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先用 `+field-list` / `+field-search-options` 确认真实选项名。 +- `link` CellValue 只表示“关联到现有记录”;它不是原生“子记录/层级记录”能力。用户只说“新增子记录”时,不要默认用自关联或双向关联字段替代。 - 这是写入操作,执行前必须确认目标表和字段。 ## 参考 diff --git a/tests/cli_e2e/base/base_field_update_dryrun_test.go b/tests/cli_e2e/base/base_field_update_dryrun_test.go new file mode 100644 index 000000000..b4d0603fd --- /dev/null +++ b/tests/cli_e2e/base/base_field_update_dryrun_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseFieldUpdateDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + t.Run("simple update stays on base v3 only", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_amount", + "--json", `{"name":"Amount","type":"number"}`, + "--yes", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_amount", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "PUT", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "number", gjson.Get(out, "api.0.body.type").String(), out) + }) + + t.Run("auto number reformat", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_auto", + "--json", `{"name":"自动编号","type":"auto_number","style":{"rules":[{"type":"text","text":"ORD-"},{"type":"created_time","date_format":"yyyyMMdd"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`, + "--reformat-existing-records", + "--yes", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_auto", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "PUT", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "/open-apis/bitable/v1/apps/app_x/tables/tbl_x/fields/fld_auto", gjson.Get(out, "api.1.url").String(), out) + require.Equal(t, "PUT", gjson.Get(out, "api.1.method").String(), out) + require.True(t, gjson.Get(out, "api.1.body.property.auto_serial.reformat_existing_records").Bool(), out) + require.Equal(t, "fixed_text", gjson.Get(out, "api.1.body.property.auto_serial.options.0.type").String(), out) + require.Equal(t, "ORD-", gjson.Get(out, "api.1.body.property.auto_serial.options.0.value").String(), out) + require.Equal(t, "system_number", gjson.Get(out, "api.1.body.property.auto_serial.options.3.type").String(), out) + require.Equal(t, "4", gjson.Get(out, "api.1.body.property.auto_serial.options.3.value").String(), out) + }) + + t.Run("reject non auto-number reformat", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_amount", + "--json", `{"name":"Amount","type":"number"}`, + "--reformat-existing-records", + "--yes", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + require.Equal(t, 2, result.ExitCode, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "--reformat-existing-records") || !strings.Contains(combined, "auto_number") { + t.Fatalf("expected reformat validation error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + }) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index b3b2435b5..10e053c1e 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -2,12 +2,13 @@ ## Metrics - Denominator: 78 leaf commands -- Covered: 18 -- Coverage: 23.1% +- Covered: 19 +- Coverage: 24.4% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. - TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data. +- TestBaseFieldUpdateDryRun: proves `+field-update` 的自动编号重排路径与非 `auto_number` fail-fast,锁定 `base/v3 -> bitable/v1` 的 dry-run 请求形状。 - TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`. - Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered. - Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite. @@ -43,7 +44,7 @@ | ✕ | base +field-get | shortcut | | none | field workflows not covered | | ✕ | base +field-list | shortcut | | none | field workflows not covered | | ✕ | base +field-search-options | shortcut | | none | field workflows not covered | -| ✕ | base +field-update | shortcut | | none | field workflows not covered | +| ✓ | base +field-update | shortcut | base_field_update_dryrun_test.go::TestBaseFieldUpdateDryRun | `--json`; `--reformat-existing-records`; `auto_number` 规则数组;dry-run only | request shape + validation fail-fast only | | ✕ | base +form-create | shortcut | | none | form workflows not covered | | ✕ | base +form-delete | shortcut | | none | form workflows not covered | | ✕ | base +form-get | shortcut | | none | form workflows not covered |