mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
10 Commits
v1.0.74
...
feat/bot-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b38f8bb9 | ||
|
|
3958bc047e | ||
|
|
215fe8a614 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 |
@@ -55,6 +55,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
h.Set("x-tt-env", "ppe_bot_user_id")
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
}
|
||||
default:
|
||||
return Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Open: "https://open.feishu-pre.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
AppLink: "https://applink.feishu.cn",
|
||||
|
||||
@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
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")
|
||||
|
||||
autoNumberRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"field-id": "fld_1",
|
||||
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
|
||||
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
|
||||
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
|
||||
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
|
||||
@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid-argument validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if wantParams != nil {
|
||||
if len(validationErr.Params) != len(wantParams) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for i, want := range wantParams {
|
||||
if validationErr.Params[i].Name != want {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
|
||||
t.Fatalf("err=%v, want message containing %q", err, messageContains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
@@ -818,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field interface{}
|
||||
submitted map[string]interface{}
|
||||
hintContains []string
|
||||
}{
|
||||
{
|
||||
name: "direct complex server type overrides simple submitted type",
|
||||
field: map[string]interface{}{"type": "auto_number"},
|
||||
submitted: map[string]interface{}{"type": "number"},
|
||||
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
|
||||
},
|
||||
{
|
||||
name: "nested simple server type still recommends readback",
|
||||
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
|
||||
submitted: map[string]interface{}{"type": "auto_number"},
|
||||
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
|
||||
},
|
||||
{
|
||||
name: "submitted simple type still recommends readback when response omits type",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"type": "text"},
|
||||
hintContains: []string{`type "text"`, "cannot determine the previous type"},
|
||||
},
|
||||
{
|
||||
name: "missing type is conservative",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"name": "Amount"},
|
||||
hintContains: []string{"unknown or uncommon field type", "+field-get"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
|
||||
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
|
||||
t.Fatalf("result=%#v, want readback recommendation", got)
|
||||
}
|
||||
hint, _ := got["verification_hint"].(string)
|
||||
for _, want := range tc.hintContains {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(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": "no operation produced",
|
||||
},
|
||||
})
|
||||
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 the API no-op response to surface as an error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed API error, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
|
||||
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T %v", err, err)
|
||||
}
|
||||
if got := stdout.String(); strings.TrimSpace(got) != "" {
|
||||
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
gotBody := string(stub.CapturedBody)
|
||||
for _, want := range []string{
|
||||
`"name":"编号"`,
|
||||
`"type":"auto_number"`,
|
||||
`"rules":[`,
|
||||
`"date_format":"yyyyMM"`,
|
||||
`"length":4`,
|
||||
} {
|
||||
if !strings.Contains(gotBody, want) {
|
||||
t.Fatalf("request body missing %q:\n%s", want, gotBody)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
|
||||
if strings.Contains(gotBody, forbidden) {
|
||||
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
|
||||
}
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"reformat_existing_records"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
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": "编号", "type": "auto_number"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
// Unknown v3 keys are forwarded unchanged; the server remains the source of
|
||||
// truth for whether a field-update property is supported.
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
|
||||
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("expected successful update, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseFieldCreate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
|
||||
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create generated field recommends readback", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1139,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
|
||||
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array with generated field recommends readback", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"Title"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"编号"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["fields"].([]interface{}); !ok {
|
||||
t.Fatalf("batch create must keep fields array: %#v", data)
|
||||
}
|
||||
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
|
||||
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1318,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "@Owner"},
|
||||
"record_id_list": []interface{}{"rec_alias_special"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{
|
||||
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
|
||||
"--field-names", `"A,B",@Owner`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1614,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
|
||||
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
|
||||
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
|
||||
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
|
||||
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append(append([]string{}, baseArgs...), tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
|
||||
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
|
||||
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
|
||||
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
|
||||
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_search"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{
|
||||
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
|
||||
"--field-names", "Name", "--field-names", "Age", "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
|
||||
@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
if name == "field-names" {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
} else {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, values := range stringSliceFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
}{
|
||||
{name: "record list", shortcut: BaseRecordList},
|
||||
{name: "record search", shortcut: BaseRecordSearch},
|
||||
{name: "record get", shortcut: BaseRecordGet},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
primary := cmd.Flags().Lookup("field-id")
|
||||
if primary == nil || primary.Hidden {
|
||||
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
|
||||
}
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, aliasName := range []string{"fields", "field-names"} {
|
||||
alias := cmd.Flags().Lookup(aliasName)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
|
||||
}
|
||||
if strings.Contains(help, "--"+aliasName) {
|
||||
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -823,6 +845,10 @@ 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",
|
||||
"Sub-record/child-record path",
|
||||
"set that link field to a parent record reference array",
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
@@ -973,11 +999,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
if strings.Contains(help, "reformat-existing-records") {
|
||||
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
`Example auto_number update: lark-cli base +field-update`,
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
|
||||
"just submit the target field definition and do not add extra low-level parameters",
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
@@ -990,6 +1022,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tips, "--reformat-existing-records") {
|
||||
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
@@ -1112,6 +1147,10 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
|
||||
t.Fatalf("formula update validate err=%v", err)
|
||||
}
|
||||
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
|
||||
t.Fatalf("auto number update validate err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableValidate(t *testing.T) {
|
||||
@@ -1233,13 +1272,89 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
|
||||
map[string][]string{"field-id": {"fld_name"}},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fields := make([]string, 51)
|
||||
for i := range fields {
|
||||
fields[i] = "Field " + strconv.Itoa(i+1)
|
||||
}
|
||||
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("50 projection fields should be accepted: %v", err)
|
||||
}
|
||||
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
|
||||
|
||||
body, marshalErr := json.Marshal(map[string]interface{}{
|
||||
"keyword": "Alice",
|
||||
"search_fields": []string{"Name"},
|
||||
"select_fields": fields,
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal search body: %v", marshalErr)
|
||||
}
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(map[string]string{
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
}, nil, nil)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("recordSearchJSONBody() error = %v", err)
|
||||
}
|
||||
if _, exists := body["select_fields"]; exists {
|
||||
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
|
||||
}
|
||||
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
|
||||
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
|
||||
}
|
||||
|
||||
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
@@ -48,7 +52,10 @@ 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")
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Body(body).
|
||||
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
|
||||
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
// fieldCreateBatchResult attaches the same top-level readback contract to a
|
||||
// multi-field create. It recommends +field-get when any submitted field is a
|
||||
// computed/linked/generated (or unknown) type, so agents know when to verify
|
||||
// server state without breaking the existing fields/total structure.
|
||||
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
|
||||
recommend := false
|
||||
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
|
||||
for _, body := range submitted {
|
||||
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
|
||||
recommend = true
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return attachFieldReadbackRecommendation(result, recommend, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
returnedType := normalizeFieldType(fieldResultType(result["field"]))
|
||||
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
|
||||
if returnedType != "" && submittedType != "" && returnedType != submittedType {
|
||||
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
|
||||
}
|
||||
|
||||
fieldType := returnedType
|
||||
if fieldType == "" {
|
||||
fieldType = submittedType
|
||||
}
|
||||
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
|
||||
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
|
||||
}
|
||||
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
|
||||
}
|
||||
|
||||
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
|
||||
result["field_get_recommended"] = readbackRecommended
|
||||
result["verification_hint"] = reason
|
||||
if readbackRecommended {
|
||||
result["next_step"] = "field_get"
|
||||
} else {
|
||||
result["next_step"] = "done"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
|
||||
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
return fieldTypeReadbackRecommendation(fieldType, operation)
|
||||
}
|
||||
|
||||
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
|
||||
fieldType = normalizeFieldType(fieldType)
|
||||
switch fieldType {
|
||||
case "formula", "lookup", "auto_number", "link":
|
||||
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
|
||||
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
|
||||
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
|
||||
default:
|
||||
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFieldType(fieldType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(fieldType))
|
||||
}
|
||||
|
||||
func fieldResultType(value interface{}) string {
|
||||
field, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
|
||||
return fieldType
|
||||
}
|
||||
nested, ok := field["field"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
|
||||
}
|
||||
|
||||
func executeFieldDelete(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
|
||||
"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.",
|
||||
|
||||
@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
|
||||
@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordListFieldAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListFieldNamesAliasFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "field-names",
|
||||
Type: "string_slice",
|
||||
Desc: "hidden alias for --field-id; accepts comma-separated field names",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("field-id") && runtime.Changed("field-names") {
|
||||
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, withValidationParam(err, projectionParam)
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok {
|
||||
if !ok || rawJSONFields == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, withValidationParam(err, "--json")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
max: max,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
for _, field := range recordListFields(runtime) {
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
for _, field := range fields {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
func recordProjectionFieldFlag(desc string) common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = desc
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordProjectionAliasFlag(name string) common.Flag {
|
||||
flagType := "string_array"
|
||||
if name == "field-names" {
|
||||
// Preserve the original compatibility contract: --field-names uses
|
||||
// pflag's CSV parser, including quoted commas, and treats @ literally.
|
||||
flagType = "string_slice"
|
||||
}
|
||||
return runtime.StrArray("field-id")
|
||||
return common.Flag{
|
||||
Name: name,
|
||||
Type: flagType,
|
||||
Desc: "hidden alias for --field-id projection",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordProjectionParam(runtime *common.RuntimeContext) string {
|
||||
switch {
|
||||
case runtime.Changed("fields"):
|
||||
return "--fields"
|
||||
case runtime.Changed("field-names"):
|
||||
return "--field-names"
|
||||
default:
|
||||
return "--field-id"
|
||||
}
|
||||
}
|
||||
|
||||
func withValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return err
|
||||
}
|
||||
reason := validationErr.Error()
|
||||
// The caller knows which input produced this validation error. Replace any
|
||||
// params inferred from the rendered message: field values such as Cost--USD
|
||||
// must not be mistaken for a --USD flag.
|
||||
validationErr.Param = param
|
||||
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
fieldIDsSet := runtime.Changed("field-id")
|
||||
fieldsSet := runtime.Changed("fields")
|
||||
fieldNamesSet := runtime.Changed("field-names")
|
||||
projectionParams := make([]string, 0, 3)
|
||||
if fieldIDsSet {
|
||||
projectionParams = append(projectionParams, "--field-id")
|
||||
}
|
||||
if fieldsSet {
|
||||
projectionParams = append(projectionParams, "--fields")
|
||||
}
|
||||
if fieldNamesSet {
|
||||
projectionParams = append(projectionParams, "--field-names")
|
||||
}
|
||||
if len(projectionParams) > 1 {
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
|
||||
for _, param := range projectionParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
|
||||
WithParam(projectionParams[0]).
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Use only --field-id for projection.")
|
||||
}
|
||||
if fieldsSet {
|
||||
return recordProjectionAliasFields(runtime, "fields", max)
|
||||
}
|
||||
if fieldNamesSet {
|
||||
return recordProjectionAliasFields(runtime, "field-names", max)
|
||||
}
|
||||
fields, err := normalizeRecordSelectFields(fieldIDs, max)
|
||||
return fields, withValidationParam(err, "--field-id")
|
||||
}
|
||||
|
||||
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
|
||||
var fields []string
|
||||
if flagName == "field-names" {
|
||||
fields = runtime.StrSlice(flagName)
|
||||
} else {
|
||||
pc := newParseCtx(runtime)
|
||||
values := runtime.StrArray(flagName)
|
||||
fields = make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
parsed, err := parseStringListFlexible(pc, raw, flagName)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
fields = append(fields, parsed...)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
err := baseFlagErrorf("--%s must include at least one field", flagName)
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
normalized, err := normalizeRecordSelectFields(fields, max)
|
||||
return normalized, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields := recordListFields(runtime)
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -174,7 +175,10 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
selectFields, err := recordSearchProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
@@ -203,6 +207,19 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSelectFields, ok := body["select_fields"]; ok {
|
||||
if rawSelectFields == nil {
|
||||
delete(body, "select_fields")
|
||||
} else {
|
||||
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
|
||||
if err != nil {
|
||||
return withValidationParam(err, "--json")
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
@@ -219,8 +236,20 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
|
||||
allParams := append([]string{"--json"}, exclusiveParams...)
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
|
||||
for _, param := range allParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--json is mutually exclusive with %s",
|
||||
strings.Join(exclusiveParams, " and "),
|
||||
).
|
||||
WithParam("--json").
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
@@ -242,17 +271,31 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit") ||
|
||||
runtime.Changed("page-size")
|
||||
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
|
||||
names := []string{
|
||||
"keyword",
|
||||
"search-field",
|
||||
"field-id",
|
||||
"fields",
|
||||
"field-names",
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if runtime.Changed(name) {
|
||||
params = append(params, "--"+name)
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
|
||||
@@ -23,7 +23,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
|
||||
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
|
||||
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
|
||||
recordListFieldRefFlag(),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
|
||||
@@ -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.",
|
||||
"Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
|
||||
"Use the record-upsert guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -67,6 +67,25 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
// selfAttendeeId resolves the open_id of the identity running the command so it
|
||||
// can be auto-added to the attendee list, mirroring how a human user is joined
|
||||
// to their own events. For a user it comes from config; for a bot it is fetched
|
||||
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
|
||||
// is still created with the explicitly requested attendees.
|
||||
func selfAttendeeId(runtime *common.RuntimeContext) string {
|
||||
if !runtime.IsBot() {
|
||||
return runtime.UserOpenId()
|
||||
}
|
||||
info, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
|
||||
err)
|
||||
return ""
|
||||
}
|
||||
return info.OpenID
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
@@ -176,7 +195,9 @@ var CalendarCreate = common.Shortcut{
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
attendeesStr := runtime.Str("attendee-ids")
|
||||
if attendeesStr != "" {
|
||||
// Note: dry-run doesn't network resolve the current user's open_id.
|
||||
// Note: dry-run doesn't network resolve the running identity's own
|
||||
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
|
||||
// self attendee is not shown here.
|
||||
attendees, err := parseAttendees(attendeesStr, "")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -228,11 +249,8 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
// Add attendees if specified
|
||||
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
||||
currentUserId := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserId = runtime.UserOpenId()
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
selfId := selfAttendeeId(runtime)
|
||||
attendees, err := parseAttendees(attendeesStr, selfId)
|
||||
if err != nil {
|
||||
return withParam(err, "--attendee-ids")
|
||||
}
|
||||
|
||||
@@ -251,6 +251,136 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_botself",
|
||||
"app_name": "Test Bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_bot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_bot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663, "msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_nobot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_nobot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
68
shortcuts/minutes/bot_identity_test.go
Normal file
68
shortcuts/minutes/bot_identity_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Tests pinning bot-identity support for `minutes +detail` (minute metadata,
|
||||
// artifacts, and transcript all flow under a tenant access token).
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestMinutesDetailSupportsUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
if !reflect.DeepEqual(MinutesDetail.AuthTypes, want) {
|
||||
t.Fatalf("MinutesDetail.AuthTypes = %v, want %v", MinutesDetail.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
|
||||
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_BotIdentity_Transcript(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--transcript", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "artifacts") {
|
||||
t.Errorf("dry-run should show artifacts API path when --transcript is set, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermissionSupportsUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
if !reflect.DeepEqual(MinutesApplyPermission.AuthTypes, want) {
|
||||
t.Fatalf("MinutesApplyPermission.AuthTypes = %v, want %v", MinutesApplyPermission.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPermission_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesApplyPermission, []string{
|
||||
"+apply-permission", "--minute-token", "obcnexampleminute", "--perm", "view", "--dry-run", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply") {
|
||||
t.Errorf("dry-run should show apply-permission API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
|
||||
t.Errorf("dry-run should show perm body, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ var MinutesApplyPermission = common.Shortcut{
|
||||
Description: "Apply for view or edit permission on a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:permission:apply"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-token", Desc: "minute token", Required: true},
|
||||
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},
|
||||
|
||||
@@ -285,7 +285,7 @@ var MinutesDetail = common.Shortcut{
|
||||
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
|
||||
|
||||
@@ -24,9 +24,12 @@ type batchCreateKR struct {
|
||||
|
||||
// batchCreateObjective represents an objective in the batch create input.
|
||||
type batchCreateObjective struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesMention []string `json:"notes_mention,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
}
|
||||
|
||||
// createdObjective tracks a created objective and its KR IDs for output.
|
||||
@@ -49,6 +52,25 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
if strings.TrimSpace(obj.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes != "" && strings.TrimSpace(obj.Notes) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes == "" && len(obj.NotesMention) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes is required when notes_mention is provided", i).WithParam("--input")
|
||||
}
|
||||
for j, mention := range obj.NotesMention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes_mention[%d] cannot be empty", i, j).WithParam("--input")
|
||||
}
|
||||
}
|
||||
if obj.CategoryID != "" {
|
||||
if strings.TrimSpace(obj.CategoryID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if id, err := strconv.ParseInt(obj.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id must be a positive int64", i).WithParam("--input")
|
||||
}
|
||||
}
|
||||
for j, kr := range obj.KRs {
|
||||
if strings.TrimSpace(kr.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
|
||||
@@ -59,11 +81,24 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
func effectiveBatchObjectiveCategoryID(defaultCategoryID string, obj batchCreateObjective) string {
|
||||
if obj.CategoryID != "" {
|
||||
return obj.CategoryID
|
||||
}
|
||||
return defaultCategoryID
|
||||
}
|
||||
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType, defaultCategoryID string, obj batchCreateObjective) (string, error) {
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
body["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
body["category_id"] = categoryID
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -156,6 +191,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
{Name: "category-id", Desc: "default objective category ID for objectives that do not set category_id"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -171,6 +207,15 @@ var OKRBatchCreate = common.Shortcut{
|
||||
if _, err := parseBatchCreateInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
categoryID := runtime.Str("category-id")
|
||||
if categoryID != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", categoryID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(categoryID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
@@ -182,6 +227,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
@@ -192,6 +238,12 @@ var OKRBatchCreate = common.Shortcut{
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
objBody["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
objBody["category_id"] = categoryID
|
||||
}
|
||||
objParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -227,6 +279,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, err := parseBatchCreateInput(runtime.Str("input"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -241,7 +294,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
// Create objective
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, defaultCategoryID, obj)
|
||||
if err != nil {
|
||||
if len(created) == 0 {
|
||||
return err
|
||||
|
||||
@@ -6,6 +6,8 @@ package okr
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
|
||||
@@ -43,6 +46,15 @@ const validBatchCreateInput = `[
|
||||
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithNotes = `[
|
||||
{"text":"Objective 1","notes":"Objective notes","notes_mention":["ou_note"],"krs":[{"text":"KR 1.1"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithCategory = `[
|
||||
{"text":"Objective 1","category_id":"222","krs":[{"text":"KR 1.1"}]},
|
||||
{"text":"Objective 2","krs":[]}
|
||||
]`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
|
||||
@@ -197,6 +209,46 @@ func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyObjectiveNotesMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes":"Notes","notes_mention":[" "]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty objective notes mention")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes_mention[0]") {
|
||||
t.Fatalf("expected error to mention objective[0].notes_mention[0], got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_NotesMentionRequiresNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes_mention":["ou_note"]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for notes_mention without notes")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes is required when notes_mention is provided") {
|
||||
t.Fatalf("expected error to mention missing notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
@@ -323,6 +375,49 @@ func TestBatchCreateDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithObjectiveNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Objective notes") {
|
||||
t.Fatalf("dry-run output should contain objective notes, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ou_note") {
|
||||
t.Fatalf("dry-run output should contain objective notes mention, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "111",
|
||||
"--input", validBatchCreateInputWithCategory,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "222" {
|
||||
t.Fatalf("first objective category_id = %q, want per-objective override 222; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.2.body.category_id").String(); got != "111" {
|
||||
t.Fatalf("second objective category_id = %q, want default 111; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
@@ -380,6 +475,94 @@ func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").Exists() {
|
||||
t.Fatalf("objective request body missing notes: %s", string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "Objective notes" {
|
||||
t.Fatalf("notes text = %q, want Objective notes; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("notes mention = %q, want ou_note; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "7249339036661170180",
|
||||
"--input", `[{"text":"Obj 1"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("category_id = %q, want 7249339036661170180; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
|
||||
394
shortcuts/okr/okr_create.go
Normal file
394
shortcuts/okr/okr_create.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// createParams holds the parsed parameters for single-object create operations.
|
||||
type createParams struct {
|
||||
Level string
|
||||
CycleID string
|
||||
ObjectiveID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
CategoryID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type createContentMultipleJSONValuesError struct{}
|
||||
|
||||
func (createContentMultipleJSONValuesError) Error() string {
|
||||
return "multiple JSON values"
|
||||
}
|
||||
|
||||
var errCreateContentMultipleJSONValues createContentMultipleJSONValuesError
|
||||
|
||||
type okrCreateRequestBody struct {
|
||||
Content *ContentBlock `json:"content"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
type okrCreateObjectiveQuery struct {
|
||||
CycleID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultQuery struct {
|
||||
ObjectiveID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveResponse struct {
|
||||
ObjectiveID string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultResponse struct {
|
||||
KeyResultID string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
}
|
||||
|
||||
type okrCreateKeyResultOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
KeyResultID string `json:"key_result_id"`
|
||||
}
|
||||
|
||||
func decodeCreateContentStrict(inputStr string, target interface{}, param, message string) error {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(inputStr)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
var trailing interface{}
|
||||
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = errCreateContentMultipleJSONValues
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCreateContentValue(inputStr, param, style string) (*ContentBlock, error) {
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := decodeCreateContentStrict(inputStr, &sp, param, fmt.Sprintf("%s must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s text is required and cannot be empty", param).WithParam(param)
|
||||
}
|
||||
for i, mention := range sp.Mention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s mention[%d] cannot be empty", param, i).WithParam(param)
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s docs and images are not supported in simple style input; use richtext style or remove these fields", param).WithParam(param)
|
||||
}
|
||||
return sp.ToContentBlock(), nil
|
||||
}
|
||||
|
||||
var cb ContentBlock
|
||||
if err := decodeCreateContentStrict(inputStr, &cb, param, fmt.Sprintf("%s must be valid ContentBlock JSON: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cb.Blocks) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must contain at least one block", param).WithParam(param)
|
||||
}
|
||||
|
||||
hasNonEmptyParagraph := false
|
||||
for _, block := range cb.Blocks {
|
||||
if block.Paragraph != nil && len(block.Paragraph.Elements) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
if block.Gallery != nil && len(block.Gallery.Images) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNonEmptyParagraph {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", param).WithParam(param)
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
func projectCreateRequestBody(body okrCreateRequestBody) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"content": body.Content,
|
||||
}
|
||||
if body.Notes != nil {
|
||||
result["notes"] = body.Notes
|
||||
}
|
||||
if body.CategoryID != "" {
|
||||
result["category_id"] = body.CategoryID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func projectCreateObjectiveQuery(query okrCreateObjectiveQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"cycle_id": query.CycleID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateKeyResultQuery(query okrCreateKeyResultQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"objective_id": query.ObjectiveID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateObjectiveResponse(data map[string]interface{}) (*okrCreateObjectiveResponse, error) {
|
||||
objectiveID, ok := data["objective_id"].(string)
|
||||
if !ok || objectiveID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
|
||||
}
|
||||
return &okrCreateObjectiveResponse{ObjectiveID: objectiveID}, nil
|
||||
}
|
||||
|
||||
func projectCreateKeyResultResponse(data map[string]interface{}) (*okrCreateKeyResultResponse, error) {
|
||||
keyResultID, ok := data["key_result_id"].(string)
|
||||
if !ok || keyResultID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
|
||||
}
|
||||
return &okrCreateKeyResultResponse{KeyResultID: keyResultID}, nil
|
||||
}
|
||||
|
||||
// parseCreateParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateParams(runtime *common.RuntimeContext) (*createParams, error) {
|
||||
p := &createParams{
|
||||
Level: runtime.Str("level"),
|
||||
CycleID: runtime.Str("cycle-id"),
|
||||
ObjectiveID: runtime.Str("objective-id"),
|
||||
Style: runtime.Str("style"),
|
||||
CategoryID: runtime.Str("category-id"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
contentStr := runtime.Str("content")
|
||||
if contentStr == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := parseCreateContentValue(contentStr, "--content", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Content = content
|
||||
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported when --level=objective").WithParam("--notes")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes, err := parseCreateContentValue(notesStr, "--notes", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Notes = notes
|
||||
}
|
||||
if p.CategoryID != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id is only supported when --level=objective").WithParam("--category-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", p.CategoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id, err := strconv.ParseInt(p.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRCreate creates a single objective or key result.
|
||||
var OKRCreate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+create",
|
||||
Description: "Create a single OKR objective or key result",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "create level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (required for level=objective)"},
|
||||
{Name: "objective-id", Desc: "objective ID (required for level=key-result)"},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "objective notes: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "category-id", Desc: "objective category ID; use only when classification is requested or the tenant requires categories"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
switch level {
|
||||
case "objective":
|
||||
if runtime.Str("objective-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id cannot be used when --level=objective").WithParam("--objective-id")
|
||||
}
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required when --level=objective").WithParam("--cycle-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--cycle-id", cycleID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
case "key-result":
|
||||
if runtime.Str("cycle-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id cannot be used when --level=key-result").WithParam("--cycle-id")
|
||||
}
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
if objectiveID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--objective-id", objectiveID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(objectiveID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
_, err := parseCreateParams(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
POST("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
params := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Set("cycle_id", p.CycleID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR objective")
|
||||
}
|
||||
|
||||
params := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/objectives/:objective_id/key_results").
|
||||
Set("objective_id", p.ObjectiveID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR key result")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
queryParams := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", p.CycleID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create objective")
|
||||
}
|
||||
resp, err := projectCreateObjectiveResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateObjectiveOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: resp.ObjectiveID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR objective [%s]\n", resp.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
queryParams := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", p.ObjectiveID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create key result")
|
||||
}
|
||||
resp, err := projectCreateKeyResultResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateKeyResultOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
KeyResultID: resp.KeyResultID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR key-result [%s] under objective [%s]\n", resp.KeyResultID, p.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
707
shortcuts/okr/okr_create_test.go
Normal file
707
shortcuts/okr/okr_create_test.go
Normal file
@@ -0,0 +1,707 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-create",
|
||||
AppSecret: patchTestValue(),
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func runCreateShortcutWithStdin(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, stdin string, args []string) error {
|
||||
t.Helper()
|
||||
f.IOStreams.In = strings.NewReader(stdin)
|
||||
return runCreateShortcut(t, f, stdout, args)
|
||||
}
|
||||
|
||||
const (
|
||||
validCreateSimpleJSON = `{"text":"test objective","mention":["ou_123"]}`
|
||||
validCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
emptyCreateRichTextJSON = `{"blocks":[]}`
|
||||
blankCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[]}}]}`
|
||||
validCreateObjectiveArgs1 = "+create"
|
||||
)
|
||||
|
||||
func TestCreateValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
validCreateObjectiveArgs1,
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid level error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingCycleIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "abc",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingObjectiveIDForKR(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectObjectiveIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected objective-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCycleIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectNotesForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective only notes"}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected notes rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCategoryIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected category-id rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--category-id" {
|
||||
t.Fatalf("expected param --category-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_ContentAndNotesCannotBothReadStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcutWithStdin(t, f, stdout, `{"text":"stdin content"}`, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", "-",
|
||||
"--notes", "-",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate stdin error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Fatalf("expected duplicate stdin error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidObjectiveID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "0",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "invalid",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid style error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--style" {
|
||||
t.Fatalf("expected param --style, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user-id-type error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--user-id-type" {
|
||||
t.Fatalf("expected param --user-id-type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "content") {
|
||||
t.Fatalf("expected required content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidSimpleContentJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid simple json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":" "}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple text error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mention":[""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple mention error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsDocsImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","docs":[{"title":"doc","url":"https://example.com"}],"images":["img"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected docs/images rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown simple content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidRichTextJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid richtext json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RichTextRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}],"mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown richtext content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptyRichTextContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
for _, content := range []string{emptyCreateRichTextJSON, blankCreateRichTextJSON} {
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", content,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected empty richtext error for %s", content)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/cycles/123/objectives" {
|
||||
t.Fatalf("dry-run url = %q, want objective create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.cycle_id").String() != "123" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "open_id" {
|
||||
t.Fatalf("expected default user-id-type in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test objective" {
|
||||
t.Fatalf("dry-run content text = %q, want test objective; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_123" {
|
||||
t.Fatalf("dry-run mention user_id = %q, want ou_123; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective notes","mention":["ou_note"]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "objective notes" {
|
||||
t.Fatalf("dry-run notes text = %q, want objective notes; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("dry-run notes mention user_id = %q, want ou_note; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "7249339036661170180",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("dry-run category_id = %q, want 7249339036661170180; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_KeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--style", "richtext",
|
||||
"--content", validCreateRichTextJSON,
|
||||
"--user-id-type", "union_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/objectives/456/key_results" {
|
||||
t.Fatalf("dry-run url = %q, want key result create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.objective_id").String() != "456" {
|
||||
t.Fatalf("expected objective-id query param in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "union_id" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test content" {
|
||||
t.Fatalf("dry-run richtext content = %q, want test content; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "objective" {
|
||||
t.Fatalf("expected level objective, got %v", data["level"])
|
||||
}
|
||||
if data["objective_id"] != "1001" {
|
||||
t.Fatalf("expected objective_id=1001, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "2001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "key-result" {
|
||||
t.Fatalf("expected level key-result, got %v", data["level"])
|
||||
}
|
||||
if data["key_result_id"] != "2001" {
|
||||
t.Fatalf("expected key_result_id=2001, got %v", data["key_result_id"])
|
||||
}
|
||||
if data["objective_id"] != "456" {
|
||||
t.Fatalf("expected objective_id=456, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveAPITypedErrorPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected typed API error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultRawErrorWrappedAsNetworkError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapOkrNetworkErr(raw, "failed to create key result")
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("expected network transport error, got: %v", got)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Fatal("expected wrapped raw error to be preserved")
|
||||
}
|
||||
if stdout.String() != "" || f == nil {
|
||||
// keep the test factory referenced so the helper wiring stays exercised
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
// Month cycles only
|
||||
if cycleStart.AddDate(1, 0, -1) == cycleEnd {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
@@ -78,6 +82,7 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
// OKRListCycles
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -89,7 +94,9 @@ var OKRListCycles = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
{Name: "time-range", Desc: "local post-filter applied after the requested page is fetched. Format: YYYY-MM--YYYY-MM. Leave empty to keep the page unfiltered."},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
@@ -110,18 +117,29 @@ var OKRListCycles = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
Desc("List one page of OKR cycles for user; --time-range is a local post-filter on the returned page")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
@@ -140,53 +158,35 @@ var OKRListCycles = common.Shortcut{
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_id_type": userIDType,
|
||||
"page_size": "100",
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
hasMore, nextPageToken := common.PaginationMeta(data)
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
@@ -212,7 +212,8 @@ var OKRListCycles = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
|
||||
@@ -5,6 +5,8 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -120,6 +123,27 @@ func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "101",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
@@ -214,6 +238,9 @@ func TestCycleListDryRun(t *testing.T) {
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
@@ -234,6 +261,28 @@ func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--page-size", "20",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 20") {
|
||||
t.Fatalf("dry-run output should contain page_size=20, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
@@ -454,9 +503,11 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); hasMore {
|
||||
t.Fatalf("has_more = %v, want false", hasMore)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
@@ -555,10 +606,13 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
var gotQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -578,38 +632,31 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "1",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "1" {
|
||||
t.Fatalf("query page_size = %q, want 1", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1", len(cycles))
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ var OKRListProgress = common.Shortcut{
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -55,6 +57,14 @@ var OKRListProgress = common.Shortcut{
|
||||
if deptIDType != "department_id" && deptIDType != "open_department_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -63,7 +73,10 @@ var OKRListProgress = common.Shortcut{
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"department_id_type": runtime.Str("department-id-type"),
|
||||
"page_size": 100,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
switch targetType {
|
||||
@@ -91,7 +104,10 @@ var OKRListProgress = common.Shortcut{
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": userIDType,
|
||||
"department_id_type": deptIDType,
|
||||
"page_size": "100",
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
var apiPath string
|
||||
@@ -103,36 +119,29 @@ var OKRListProgress = common.Shortcut{
|
||||
}
|
||||
|
||||
var allProgress []*Progress
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
}
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
|
||||
// Convert to response format
|
||||
respProgress := make([]*RespProgress, 0, len(allProgress))
|
||||
for _, p := range allProgress {
|
||||
@@ -141,7 +150,8 @@ var OKRListProgress = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"progress_list": respProgress,
|
||||
"total": len(respProgress),
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress))
|
||||
for _, p := range respProgress {
|
||||
|
||||
@@ -5,11 +5,14 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -123,6 +126,28 @@ func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
@@ -144,6 +169,9 @@ func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
@@ -164,14 +192,41 @@ func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "25",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 25") {
|
||||
t.Fatalf("dry-run output should contain page_size=25, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
var gotQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123456789/progresses",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -191,7 +246,8 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -199,15 +255,32 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "50",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "50" {
|
||||
t.Fatalf("query page_size = %q, want 50", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
records, _ := data["progress_list"].([]interface{})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 progress, got %d", len(records))
|
||||
}
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListExecute_Success_KeyResult(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,7 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRCreate,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
|
||||
@@ -12,6 +12,12 @@ import (
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
commands := make([]string, 0, len(list))
|
||||
for _, shortcut := range list {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
convey.So(commands, convey.ShouldContain, "+create")
|
||||
convey.So(commands, convey.ShouldContain, "+batch-create")
|
||||
convey.So(commands, convey.ShouldContain, "+patch")
|
||||
})
|
||||
}
|
||||
|
||||
145
shortcuts/vc/bot_identity_test.go
Normal file
145
shortcuts/vc/bot_identity_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Tests pinning bot-identity support for the vc read shortcuts
|
||||
// (+detail / +notes / +recording).
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthTypes contracts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVCReadShortcutsSupportUserAndBotIdentity(t *testing.T) {
|
||||
want := []string{"user", "bot"}
|
||||
cases := map[string][]string{
|
||||
"+detail": VCDetail.AuthTypes,
|
||||
"+notes": VCNotes.AuthTypes,
|
||||
"+recording": VCRecording.AuthTypes,
|
||||
}
|
||||
for cmd, got := range cases {
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s AuthTypes = %v, want %v", cmd, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot dry-run: the meeting/recording paths flow under bot identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/meetings/{meeting_id}") {
|
||||
t.Errorf("dry-run should show meeting.get API, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_DryRun_BotIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/notes/{note_id}") {
|
||||
t.Errorf("dry-run should show note.get API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar-event-ids also flows under bot: a bot has a primary calendar, so the
|
||||
// primary-calendar -> meeting_id -> recording/notes chain is expected to work.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error under --as bot: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity-aware preflight: bot resolves TAT (empty local scopes in this stub),
|
||||
// so an under-scoped UAT must not make --as bot fail. Reverting to
|
||||
// auth.GetStoredToken(user) would break this.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_BotIdentityAwareScopePreflight(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingIdentityTokenResolver{
|
||||
uatScopes: "calendar:calendar:read", // deliberately missing vc:record:readonly
|
||||
tatScopes: "", // bot/tenant: no local scope metadata
|
||||
}, nil)
|
||||
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("bot preflight must resolve tenant token, not the under-scoped user token; got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingIdentityTokenResolver returns different scopes for UAT vs TAT so
|
||||
// bot identity-aware preflight can be pinned separately from user preflight.
|
||||
type recordingIdentityTokenResolver struct {
|
||||
uatScopes string
|
||||
tatScopes string
|
||||
}
|
||||
|
||||
func (r *recordingIdentityTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
scopes := r.uatScopes
|
||||
if req.Type == credential.TokenTypeTAT {
|
||||
scopes = r.tatScopes
|
||||
}
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: scopes}, nil
|
||||
}
|
||||
@@ -164,7 +164,7 @@ var VCDetail = common.Shortcut{
|
||||
Description: "Get meeting details including note_id and minute_token by meeting IDs",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
|
||||
|
||||
@@ -536,7 +536,7 @@ var VCNotes = common.Shortcut{
|
||||
Description: "Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -91,13 +92,13 @@ var VCRecording = common.Shortcut{
|
||||
Description: "Query minute_token from meeting-ids or calendar-event-ids",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:record:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
||||
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.ExactlyOneTyped(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,18 +117,14 @@ var VCRecording = common.Shortcut{
|
||||
case runtime.Str("calendar-event-ids") != "":
|
||||
required = scopesRecordingCalendarEventIDs
|
||||
}
|
||||
appID := runtime.Config.AppID
|
||||
userOpenID := runtime.UserOpenId()
|
||||
if appID != "" && userOpenID != "" {
|
||||
stored := auth.GetStoredToken(appID, userOpenID)
|
||||
if stored != nil {
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -141,27 +139,15 @@ func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecording_Validate_MissingScope(t *testing.T) {
|
||||
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := defaultConfig()
|
||||
// Store a token that intentionally lacks the vc:record:readonly scope.
|
||||
token := &auth.StoredUAToken{
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AppId: cfg.AppID,
|
||||
AccessToken: "test-user-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "calendar:calendar:read",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
// TestFactory's default token resolver returns empty Scopes, which skips
|
||||
// identity-aware preflight. Inject a resolver that returns an under-scoped
|
||||
// user token so the MissingScopes path is exercised.
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingScopedTokenResolver{
|
||||
scopes: "calendar:calendar:read",
|
||||
}, nil)
|
||||
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_scope error, got nil")
|
||||
@@ -189,6 +175,16 @@ func TestRecording_Validate_MissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingScopedTokenResolver returns a token with caller-controlled scopes
|
||||
// so tests can deterministically exercise the identity-aware scope preflight.
|
||||
type recordingScopedTokenResolver struct {
|
||||
scopes string
|
||||
}
|
||||
|
||||
func (r *recordingScopedTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
728
shortcuts/whiteboard/whiteboard_export.go
Normal file
728
shortcuts/whiteboard/whiteboard_export.go
Normal file
@@ -0,0 +1,728 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardExportAsPreview exports a whiteboard preview image.
|
||||
WhiteboardExportAsPreview = "preview"
|
||||
// WhiteboardExportAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardExportAsSvg = "svg"
|
||||
// WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardExportAsSource = "source"
|
||||
// WhiteboardExportAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardExportAsRaw = "raw"
|
||||
|
||||
// Legacy output type names accepted for backward compatibility.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
|
||||
WhiteboardQueryAsSvg = WhiteboardExportAsSvg
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
|
||||
WhiteboardQueryAsRaw = WhiteboardExportAsRaw
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
var wbExportScopes = []string{"board:whiteboard:node:read"}
|
||||
var wbExportAuthTypes = []string{"user", "bot"}
|
||||
var wbExportFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
var wbQueryFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
|
||||
if !ok {
|
||||
return "", "--output-type"
|
||||
}
|
||||
return normalized, "--output-type"
|
||||
}
|
||||
|
||||
func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
|
||||
if !ok {
|
||||
return "", "--output_as"
|
||||
}
|
||||
return normalized, "--output_as"
|
||||
}
|
||||
|
||||
func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardExportAsSvg:
|
||||
return WhiteboardExportAsSvg, true
|
||||
case WhiteboardExportAsSource:
|
||||
return WhiteboardExportAsSource, true
|
||||
case WhiteboardExportAsRaw:
|
||||
return WhiteboardExportAsRaw, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardQueryAsImage:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardQueryAsCode:
|
||||
return WhiteboardExportAsSource, true
|
||||
default:
|
||||
return normalizeWhiteboardExportOutputType(outputType)
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportOutputTypeError(param string) *errs.ValidationError {
|
||||
if param == "--output_as" {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output_as flag must be one of: image | svg | code | raw",
|
||||
).WithParam("--output_as")
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output-type flag must be one of: preview | svg | source | raw",
|
||||
).WithParam("--output-type")
|
||||
}
|
||||
|
||||
func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
if outputType == "" {
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && outputType == WhiteboardExportAsPreview {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardExportAsSource:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml source from given whiteboard")
|
||||
case WhiteboardExportAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardExportAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
if outputTypeParam == "--output_as" {
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardExportAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardExportAsSource:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardExportAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
}
|
||||
|
||||
const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
|
||||
|
||||
// WhiteboardExport registers the `whiteboard +export` shortcut.
|
||||
var WhiteboardExport = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+export",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbExportFlags,
|
||||
HasFormat: true,
|
||||
Validate: wbExportValidate,
|
||||
DryRun: wbExportDryRun,
|
||||
Execute: wbExportExecute,
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbQueryFlags,
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
Validate: wbQueryValidate,
|
||||
DryRun: wbQueryDryRun,
|
||||
Execute: wbQueryExecute,
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
|
||||
var whiteboardPreviewContentTypeExt = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
}
|
||||
|
||||
func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
|
||||
contentType := header.Get("Content-Type")
|
||||
ext, err := whiteboardPreviewExtFromContentType(contentType)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return saveResolvedOutputFile(finalPath, contentType, runtime, data)
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
|
||||
return ext, nil
|
||||
}
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
contentType = "<empty>"
|
||||
}
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
|
||||
contentType,
|
||||
)
|
||||
}
|
||||
|
||||
func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
currentExt := strings.ToLower(filepath.Ext(outPath))
|
||||
if currentExt == "" || currentExt == "." {
|
||||
finalPath := strings.TrimSuffix(outPath, ".") + ext
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if !isWhiteboardPreviewImageExt(currentExt) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if !whiteboardPreviewExtMatches(currentExt, ext) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"preview response is %s but output path has extension %s; use a matching extension or omit the extension",
|
||||
ext,
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func isWhiteboardPreviewImageExt(ext string) bool {
|
||||
return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
|
||||
if responseExt == ".jpg" {
|
||||
return outputExt == ".jpg" || outputExt == ".jpeg"
|
||||
}
|
||||
return outputExt == responseExt
|
||||
}
|
||||
|
||||
func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
_, err := runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -211,6 +212,73 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
|
||||
// and output type names while legacy +query validation remains covered above.
|
||||
func TestWhiteboardExport_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantErr bool
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "valid: preview with output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
"output": "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid: source without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "source",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid: preview without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output",
|
||||
},
|
||||
{
|
||||
name: "invalid: bad output-type value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "image",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output-type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
|
||||
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
|
||||
// a flat legacy error.
|
||||
@@ -284,7 +352,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output": "output.png",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "dry run code",
|
||||
@@ -293,7 +361,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "code",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
},
|
||||
{
|
||||
name: "dry run raw",
|
||||
@@ -302,7 +370,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -313,6 +381,29 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
if dryRun == nil {
|
||||
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
|
||||
}
|
||||
if got.API[0].URL != tt.wantPath {
|
||||
t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -391,6 +482,32 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
if len(WhiteboardQuery.Flags) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
||||
}
|
||||
if !WhiteboardQuery.Hidden {
|
||||
t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
|
||||
}
|
||||
|
||||
// Verify WhiteboardExport is the visible canonical shortcut.
|
||||
if WhiteboardExport.Command != "+export" {
|
||||
t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
|
||||
}
|
||||
if WhiteboardExport.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
|
||||
}
|
||||
if WhiteboardExport.Hidden {
|
||||
t.Errorf("WhiteboardExport should be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
|
||||
t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardExport --output-type should exist and be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
|
||||
t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
|
||||
@@ -862,10 +979,11 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
|
||||
// Mock download preview image API response with RawBody
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
ContentType: "image/png",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
||||
@@ -883,6 +1001,158 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
|
||||
// downloads are saved according to the API response Content-Type rather than a
|
||||
// hard-coded PNG suffix.
|
||||
func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("<html>bad gateway</html>"),
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-image preview response")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
|
||||
data, readErr := os.ReadFile("report.html")
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile() error: %v", readErr)
|
||||
}
|
||||
if string(data) != "keep me" {
|
||||
t.Fatalf("report.html was overwritten: %q", string(data))
|
||||
}
|
||||
if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Content-Disposition": []string{`attachment; filename="payload.sh"`},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched explicit extension")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
|
||||
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("report.jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -1522,3 +1792,12 @@ func chdirTemp(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == name {
|
||||
return &shortcut.Flags[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardQueryAsImage exports a whiteboard preview image.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardQueryAsSvg = "svg"
|
||||
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
as := runtime.Str("output_as")
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardQueryAsCode:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml code from given whiteboard")
|
||||
case WhiteboardQueryAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardQueryAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 构建 API 请求
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
as := runtime.Str("output_as")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,16 +87,20 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `created: true`。
|
||||
- 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。
|
||||
2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 这是写入操作,执行前必须确认。
|
||||
- ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。
|
||||
- ⚠️ 不要把“每次创建后都 `+field-get`”当作固定流程;按返回里的 `field_get_recommended` 和 `next_step` 决定是否读回。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -180,11 +180,11 @@
|
||||
|
||||
支持字段:`icon`、`min`、`max`
|
||||
|
||||
默认值 / 约束:
|
||||
默认值 / 已知平台范围:
|
||||
- `icon` 默认 `star`
|
||||
- `icon` 可用:`star`、`heart`、`thumbsup`、`fire`、`smile`、`lightning`、`flower`、`number`
|
||||
- `min` 取值 `0..1`,默认 `1`
|
||||
- `max` 取值 `1..10`,默认 `5`
|
||||
- `max` 默认 `5`;常见或已文档化的范围为 `1..10`,但 CLI 不强制上限为 `10`。如果用户明确需要更大评分范围,优先确认平台能力或用 `+field-create/update --dry-run` 检查请求形状;平台拒绝后再建议改用普通数字或进度字段。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -419,7 +419,7 @@
|
||||
|
||||
### 3.11 auto_number
|
||||
|
||||
自动编号字段;不写 `style.rules` 时使用默认规则:`NO.001`。
|
||||
自动编号字段;创建时不写 `style.rules` 会使用默认规则:`NO.001`。更新已有自动编号字段时应显式提交目标 `style.rules`,因为 `+field-update` 会把新的编号规则重新应用到已有编号。
|
||||
|
||||
最小写法:
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
## 4. 创建与更新
|
||||
|
||||
- `+field-create`:按目标字段配置直接构造 `--json`。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。当 `type` 是 `auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。
|
||||
|
||||
## 5. 暂不支持字段
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ lark-cli base +field-update \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
|
||||
--yes
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -42,6 +49,8 @@ lark-cli base +field-update \
|
||||
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
```
|
||||
|
||||
当 `--json.type` 是 `auto_number` 时,仍然走同一个 v3 字段更新接口:更新自动编号规则后,接口现状就会把新规则应用到已有编号(这是接口默认行为,只是 agent 通常不知道),因此**不需要**任何额外开关或参数。只需要正常提交目标自动编号字段定义即可;如果用户要求“将修改用于已有编号”,直接执行这次 `+field-update` 就能达到效果,不要在 `--json` 里额外添加任何参数去“触发”重排。
|
||||
|
||||
## JSON 值规范
|
||||
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
@@ -52,6 +61,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
- 现有 `link` 字段的 `bidirectional` 不能改。
|
||||
- `auto_number` 更新的 `style.rules` 支持 `text`、`created_time`、`incremental_number`。
|
||||
|
||||
**推荐更新示例**
|
||||
|
||||
@@ -83,13 +93,18 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `updated: true`。
|
||||
- `updated:true` 只表示更新请求成功,不表示字段结构、已有记录值或下游能力已经完成验证。`+field-update` 无法知道更新前的字段类型,因此成功响应会推荐执行 `+field-get`;若发生类型转换,还要抽样读取记录值。
|
||||
- 如果响应中的 `field.type` 与提交的 `type` 不一致,必须把它当作待核验的类型不匹配;不能返回完成态,也不能只根据其中任一类型推断更新成功。
|
||||
- 如果 API 报告本次更新没有产生任何变更(no-op),命令会如实返回该错误;这通常说明目标字段已是期望状态,不要机械重试同一份 `+field-update`。需要确认当前字段完整状态时执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. 建议先用 `+field-get` 拉现状,再做最小化修改。
|
||||
2. `formula/lookup` 类型更新前先阅读对应指南。
|
||||
3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
3. 如果更新 `auto_number`,理解为“更新编号规则,同时把新规则应用到已有编号”;执行后按返回提示读回字段并在必要时抽样记录值。
|
||||
4. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
|
||||
## 字段类型变更规则
|
||||
|
||||
@@ -155,6 +170,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
### 完成态验证
|
||||
|
||||
- `FieldReadback`: 读回字段结构,确认 `type` / `multiple` / `style` / `options`
|
||||
- `NoopReadback`: `+field-update` 返回 no-op 错误时,只能说明 API 报告没有产生变更;可以跳过重复 update,但不能替代 `FieldReadback`
|
||||
- `ValueReadback`: 抽样读回转换后的单元格值
|
||||
- `DownstreamReadback`: 若涉及看板 / 分组 / 排序 / lookup / 公式,继续读回结果
|
||||
- `CompletionRule`: 结构、值、下游能力都正确,才能回复“已完成”
|
||||
|
||||
@@ -44,6 +44,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -87,13 +87,21 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
"document": {
|
||||
"document_id": "doxcnXXXX",
|
||||
"revision_id": 12,
|
||||
"content": "<title>标题</title><p>文档内容...</p>"
|
||||
"content": "<title>标题</title><p>文档内容...</p>",
|
||||
"reference_map": {
|
||||
"<block_type>": {
|
||||
"<ref>": {
|
||||
"<real-attr-key>": "<real-attr-value>"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tips": "<safe replay or degradation guidance>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
## 参数
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Wor
|
||||
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output-type preview \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
|
||||
|
||||
## HTML5 block
|
||||
|
||||
1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
2. 格式如下:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="use-iframe" content="true">
|
||||
<meta name="html-box-height-mode" content="auto">
|
||||
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 布局与高度
|
||||
|
||||
- `lark-cli` 会读取 `.html` 文件并原样写入 `reference_map`,不会解析或校验 `html-box-height-mode`;创建或更新文档前在 `<head>` 中显式声明 `auto` 或 `viewport`。
|
||||
- 生成时只使用 `auto` 或 `viewport`,不要臆造 `fixed`、`initial` 或像素值等其他 mode。
|
||||
- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%`、`max-width: 100%`、`box-sizing: border-box`。
|
||||
|
||||
四种策略:
|
||||
|
||||
1. 内容自然撑开:`auto` + 普通文档流;根容器不设固定高度或 `overflow: hidden`。
|
||||
2. 仅按初始内容定高:`auto` + 首次渲染后不再追加或展开内容。
|
||||
3. 固定像素操作区:`auto` + 业务容器按场景设置固定的 CSS `height` 和 `overflow: auto`;高度数值不写进 meta。
|
||||
4. 单屏应用:`viewport` + `100vh` + 内部滚动、切页或缩放;适用于游戏、幻灯片、Dashboard、canvas 编辑器。
|
||||
|
||||
正文需要在飞书文档中完整展开时选 `auto`;内容应在 HTML Block 内滚动时选 `viewport`。`lark-cli` 不参与页面加载后的高度刷新,不要臆造相关 CLI flag。
|
||||
|
||||
### 内容限制
|
||||
|
||||
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
|
||||
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR,不构造 Objective/KR/Progress 子树。
|
||||
|
||||
@@ -23,7 +23,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
## 行内组件
|
||||
| 标签 | 说明 | 关键属性 |
|
||||
|-|-|-|
|
||||
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id`:`<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
|
||||
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
|
||||
@@ -46,8 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>`、`<okr>` — 前者在飞书文档「HTML 块」iframe 中加载单文件 HTML,内容可用 HTML 渲染时直接使用;后者创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR。完整语法与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
|
||||
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
|
||||
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR;完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
@@ -85,6 +85,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
- 任何包含 `<cite type="user">` 的 XML 在导入、新建或编辑回写时,都必须显式传入 `user-id`;其值为用户的 `open_id`,不得省略。
|
||||
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
|
||||
- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
|
||||
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
|
||||
|
||||
@@ -190,9 +190,9 @@ lark-cli base +record-list --base-token '<base_token>' --table-id '<table_id>' -
|
||||
- 若要定位画板内部节点,切到 `lark-whiteboard` 读取 raw 节点结构:
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token '<whiteboard_token>' \
|
||||
--output_as raw
|
||||
--output-type raw
|
||||
```
|
||||
|
||||
- 如果 raw 节点中存在唯一匹配 `quote` 的文本节点,可定位到该节点;如果有多个相同文本节点,仍然是弱匹配,需要结合位置、样式、用户描述或人工确认。
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
所有 minutes 命令默认使用 `--as user`。
|
||||
所有 minutes 命令默认使用 `--as user`。`+detail` 和 `+download` 也支持 `--as bot`(bot 只能访问 bot 有权限的妙记)。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
|
||||
@@ -14,30 +14,85 @@ metadata:
|
||||
|
||||
**身份**:OKR 操作默认使用 `--as user`(查看当前用户/上下级的 OKR 时)。也支持 `--as bot` 查看他人 OKR(需相应权限)。
|
||||
|
||||
## 快速决策
|
||||
|
||||
| 用户需求 | 操作路径 | 参考文档 |
|
||||
|----------------|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 查看自己/他人的 OKR | 获取用户 ID -> `+cycle-list` -> `+cycle-detail` -> 按需查指标/进展记录 | [`cycle-list`](references/lark-okr-cycle-list.md), [`cycle-detail`](references/lark-okr-cycle-detail.md), [`indicators`](references/lark-okr-indicators.md), [`progress-list`](references/lark-okr-progress-list.md) |
|
||||
| 为自己写一组 OKR | 优先用 `+batch-create` 创建 Objective/KR 骨架 | [`batch-create`](references/lark-okr-batch-create.md), [`contentblock`](references/lark-okr-contentblock.md) |
|
||||
| 只新增一条 O 或单条 KR | 用 `+create` | [`create`](references/lark-okr-create.md) |
|
||||
| 编辑内容/备注/截止时间 | 用 `+patch` | [`patch`](references/lark-okr-patch.md) |
|
||||
| 修改 OKR 分数 | 只有用户明确说“分数”“评分”“打分”“score”时才用 `+patch --score`;分数不是进度/完成度 | [`patch`](references/lark-okr-patch.md) |
|
||||
| 调整顺序或权重 | 用 `+reorder` / `+weight` | [`reorder`](references/lark-okr-reorder.md), [`weight`](references/lark-okr-weight.md) |
|
||||
| 更新数字进度/完成度 | 百分比或不带单位数字用 `+indicator-update`;需要改单位/目标值时查指标后用 `indicators patch` | [`indicator-update`](references/lark-okr-indicator-update.md), [`indicators`](references/lark-okr-indicators.md) |
|
||||
| 写文字进展 | 用 `+progress-create`;如果文本和数字都有,百分比或默认单位可使用 `--progress-percent` 统一改,非百分比单位更新量化指标 | [`progress-create`](references/lark-okr-progress-create.md), [`progress-list`](references/lark-okr-progress-list.md), [`progress-update`](references/lark-okr-progress-update.md) |
|
||||
| 对齐目标 | 直接按对齐关系工作流处理 | [`alignments`](references/lark-okr-alignments.md) |
|
||||
|
||||
分类只在用户明确要求分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时处理:用 `lark-cli okr categories list --params '{"owner_type":"user","page_size":100}' --as user` 查可用分类,选择语义合适且 `enabled=true` 的分类 ID;分类可后续调整,不必停下等待用户确认。
|
||||
|
||||
获取当前用户用 `contact +get-user`;按姓名/邮箱查他人用 `contact +search-user`,拿到 `open_id` 后再查 OKR。
|
||||
|
||||
```bash
|
||||
lark-cli contact +search-user --query "张三" --has-chatted --as user
|
||||
```
|
||||
|
||||
最常用 OKR 命令示例:
|
||||
|
||||
```bash
|
||||
# 查用户周期,再用周期 ID 查详情
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --as user
|
||||
lark-cli okr +cycle-detail --cycle-id 7000000000000000001 --as user
|
||||
|
||||
# 批量创建 Objective/KR
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input '[{"text":"提升产品用户体验","notes":"关注核心流程和用户反馈","krs":[{"text":"核心流程满意度达到 4.8 分"}]}]' \
|
||||
--as user
|
||||
|
||||
# 更新数字进度/完成度
|
||||
lark-cli okr +indicator-update \
|
||||
--level key-result \
|
||||
--id 7000000000000000003 \
|
||||
--value 75 \
|
||||
--as user
|
||||
```
|
||||
|
||||
分数和进度不要混用:用户说“进度”“完成度”“当前做到 75%”时,通常是在改量化指标或写进展记录,不是在改 `score`。只有明确要求修改 OKR 分数/评分/打分时,才使用 [`+patch --score`](references/lark-okr-patch.md);`score` 取值是 0-1,最多一位小数。
|
||||
|
||||
进度判断规则:用户说“进度”“完成度”时,先判断是否是量化数字。数字进度通常对应量化指标;不可量化文本对应进展记录。需要修改指标单位时看 [`lark-okr-indicators.md`](references/lark-okr-indicators.md)
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
|
||||
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) |
|
||||
| Shortcut | 说明 |
|
||||
|----------------------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 分页获取特定用户的 OKR 周期列表,可以用 `--time-range` 对当前页后置筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+create`](references/lark-okr-create.md) | 创建单个 Objective(可带备注),或向已有 Objective 新增 KR |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 分页获取目标或关键结果的进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective(可带备注)和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的当前进度指标。更复杂的量化指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
|
||||
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) |
|
||||
|
||||
### 创建场景选择
|
||||
|
||||
- **单条创建优先用 [`+create`](references/lark-okr-create.md)**:适合创建一个 Objective,或给已有 Objective 增加一个 KR。
|
||||
- **批量创建用 [`+batch-create`](references/lark-okr-batch-create.md)**:适合一次创建多个 Objective,并可同时附带多个 KR。
|
||||
- 如果你只需要修改已有 Objective / KR 的内容、备注、分数或截止时间,使用 [`+patch`](references/lark-okr-patch.md)。
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note
|
||||
字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
@@ -56,18 +111,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
### cycles
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- 请求中必须携带对应周期下全部目标的 ID,否则会参数校验失败。以传入的目标ID顺序重新排列目标。
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。例如周期下有 2 个目标时:
|
||||
- 正确指令示例如下:
|
||||
``` bash
|
||||
lark-cli okr cycles objectives_weight --params '{"cycle_id": "7000000000000000001"}' --data '{"objective_weights": [{"objective_id": "7000000000000000002", "weight": 0.7}, {"objective_id": "7000000000000000003", "weight": 0.3}]}' --as user
|
||||
```
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
- `create` — 创建目标
|
||||
- `list` — 批量获取用户周期下的目标
|
||||
|
||||
### indicators
|
||||
@@ -110,7 +156,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
|
||||
### objective.key_results
|
||||
|
||||
- `create` — 创建关键结果
|
||||
- `list` — 批量获取目标下的关键结果
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
@@ -10,23 +10,7 @@
|
||||
# 批量创建 2 个 Objective,各带 2 个 KR。
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input '[
|
||||
{
|
||||
"text": "提升产品用户体验",
|
||||
"mention": ["ou_xxxxxxxx"],
|
||||
"krs": [
|
||||
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
|
||||
{"text": "用户满意度达到 4.8 分"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "拓展新市场份额",
|
||||
"krs": [
|
||||
{"text": "新增 10 个城市覆盖"},
|
||||
{"text": "市场份额提升至 25%"}
|
||||
]
|
||||
}
|
||||
]' \
|
||||
--input '[{"text":"提升产品用户体验","mention":["ou_xxxxxxxx"],"notes":"重点关注核心路径体验","krs":[{"text":"页面加载速度提升 50%","mention":["ou_yyyyyyyy"]},{"text":"用户满意度达到 4.8 分"}]},{"text":"拓展新市场份额","krs":[{"text":"新增 10 个城市覆盖"},{"text":"市场份额提升至 25%"}]}]' \
|
||||
--as user
|
||||
|
||||
# 从文件读取输入
|
||||
@@ -44,17 +28,22 @@ lark-cli okr +batch-create \
|
||||
```
|
||||
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
|
||||
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
|
||||
- Objective 的 notes / notes_mention 是可选参数,用于创建目标备注;KR 不支持备注。
|
||||
- Objective 的 category_id 是可选参数;也可以通过 `--category-id` 给所有未显式设置分类的 Objective 指定默认分类。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|------------------------------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型) |
|
||||
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
|
||||
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
|
||||
| `--category-id` | 否 | — | 默认 Objective 分类 ID。仅用于 input 中未设置 `category_id` 的 Objective。通常不需要传入,见下方“分类提示”。 |
|
||||
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 category-id 字段进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
|
||||
|
||||
## 输入格式
|
||||
|
||||
```json
|
||||
@@ -62,6 +51,9 @@ lark-cli okr +batch-create \
|
||||
{
|
||||
"text": "Objective 内容",
|
||||
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
|
||||
"notes": "Objective 备注",
|
||||
"notes_mention": ["ou_xxxxxxxx"],
|
||||
"category_id": "7249339036661170180",
|
||||
"krs": [
|
||||
{
|
||||
"text": "KR 内容",
|
||||
@@ -72,6 +64,15 @@ lark-cli okr +batch-create \
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `text`:Objective 或 KR 内容,必填。
|
||||
- `mention`:追加到内容后的用户 mention,可选。
|
||||
- `notes`:Objective 备注文本,可选,仅 Objective 支持。
|
||||
- `notes_mention`:追加到 Objective 备注后的用户 mention,可选,仅在 `notes` 存在时有意义。
|
||||
- `category_id`:Objective 分类 ID,可选;会覆盖命令级 `--category-id`。
|
||||
- `krs`:当前 Objective 下要创建的 KR 列表,可选。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
|
||||
|
||||
173
skills/lark-okr/references/lark-okr-create.md
Normal file
173
skills/lark-okr/references/lark-okr-create.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# okr +create
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建单个 OKR 目标(Objective)或关键结果(Key Result)。这是单条写入场景的首选 shortcut;如果需要一次创建多个 Objective 及其 KR,可使用 [`+batch-create`](lark-okr-batch-create.md)。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 在指定周期下创建一个 Objective(默认 simple 风格)
|
||||
lark-cli okr +create \
|
||||
--level objective \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--content '{"text":"提升北极星指标","mention":["ou_xxxxxxxx"]}' \
|
||||
--notes '{"text":"重点关注活跃用户和转化漏斗"}' \
|
||||
--as user
|
||||
|
||||
# 在已有 Objective 下创建一个 KR
|
||||
lark-cli okr +create \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--content '{"text":"季度留存率提升到 45%"}' \
|
||||
--as user
|
||||
|
||||
# 使用 richtext 风格创建 Objective(完整 ContentBlock JSON)
|
||||
lark-cli okr +create \
|
||||
--level objective \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--style richtext \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"建立跨部门协作机制"}}]}}]}' \
|
||||
--as user
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +create \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--content '{"text":"完成 3 次核心流程优化"}' \
|
||||
--dry-run \
|
||||
--as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `--level` | 是 | — | 创建层级:`objective`(创建目标)\| `key-result`(在已有目标下创建 KR) |
|
||||
| `--cycle-id` | 条件 | — | OKR 周期 ID(int64 类型)。当 `--level=objective` 时**必填**。 |
|
||||
| `--objective-id` | 条件 | — | Objective ID(int64 类型)。当 `--level=key-result` 时**必填**。 |
|
||||
| `--style` | 否 | `simple` | 内容输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--content` | 是 | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
|
||||
| `--notes` | 否 | — | Objective 备注,仅 `--level=objective` 支持。根据 `--style` 指定格式,支持 `@文件路径` 或 `-` 从 stdin 读取。 |
|
||||
| `--category-id` | 否 | — | Objective 分类 ID,仅 `--level=objective` 支持。通常不需要传入,见下方“分类提示”。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id`。影响 mention 中用户 ID 的解释方式。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 --category-id 参数进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `--category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
|
||||
|
||||
## 输入格式
|
||||
|
||||
### `--style simple`(默认)
|
||||
|
||||
推荐大多数创建场景使用 `simple` 风格。`--content` 和 `--notes` 都使用 `SemiPlainContent` JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "提升北极星指标",
|
||||
"mention": ["ou_xxxxxxxx"]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `text` 必填,且不能为空白字符串
|
||||
- `mention` 可选;如果传入,数组中的每个用户 ID 都不能为空字符串
|
||||
- `--notes` 仅适用于 Objective;创建 KR 时传 `--notes` 会报错
|
||||
- 同一条命令只有一个 flag 可以使用 `-` 读取 stdin;如果 `--content -`,`--notes` 请使用内联 JSON 或 `@文件路径`
|
||||
|
||||
### `--style richtext`
|
||||
|
||||
当你需要精确控制段落结构、插入文档链接,或使用完整富文本块结构时,使用 `richtext` 风格:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "建立跨部门协作机制"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `blocks` 至少需要有一个非空段落或图片块
|
||||
- 不能传空 `blocks`,也不能传只有空段落元素的内容
|
||||
- 更多结构说明见 [ContentBlock 富文本格式](lark-okr-contentblock.md)
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 如果要创建 Objective,先使用 `+cycle-list` 获取目标周期的 `cycle_id`。
|
||||
2. 如果要给已有 Objective 新增 KR,先通过 `+cycle-detail` 或其他 OKR 查询命令拿到 `objective_id`。
|
||||
3. 选择输入风格:
|
||||
- **推荐**:`simple`,适合普通文本和 mention。
|
||||
- 需要复杂富文本时:`richtext`。
|
||||
4. 执行 `lark-cli okr +create ...`。
|
||||
5. 报告结果:
|
||||
- 创建 Objective 时返回新的 `objective_id`
|
||||
- 创建 KR 时返回新的 `key_result_id`,并附带父 `objective_id`
|
||||
|
||||
## Dry-run 对应接口
|
||||
|
||||
- `--level=objective`:
|
||||
- `POST /open-apis/okr/v2/cycles/:cycle_id/objectives`
|
||||
- `--level=key-result`:
|
||||
- `POST /open-apis/okr/v2/objectives/:objective_id/key_results`
|
||||
|
||||
## 输出
|
||||
|
||||
### 创建 Objective 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "objective",
|
||||
"objective_id": "7000000000000000002"
|
||||
}
|
||||
```
|
||||
|
||||
### 创建 KR 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "key-result",
|
||||
"objective_id": "7000000000000000002",
|
||||
"key_result_id": "7000000000000000003"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见错误与处理
|
||||
|
||||
- `--level=objective` 但未传 `--cycle-id`
|
||||
- 补充有效的周期 ID
|
||||
- `--level=key-result` 但未传 `--objective-id`
|
||||
- 补充已有 Objective 的 ID
|
||||
- `--content` 为空、不是合法 JSON,或内容结构为空
|
||||
- 按 `--style` 对应格式修正输入
|
||||
- 在 `simple` 风格中传了 `docs` 或 `images`
|
||||
- 改用 `--style richtext`,或移除这些字段
|
||||
|
||||
## 何时用 +create,何时用 +batch-create
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|------|----------|
|
||||
| `+create` | 创建单个 Objective,或向已有 Objective 新增单个 KR |
|
||||
| `+batch-create` | 一次创建多个 Objective,并可同时为每个 Objective 创建多个 KR |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [OKR 业务实体](lark-okr-entities.md) -- Objective、KR、周期等基础概念
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 字段的另一种输入风格,支持完整富文本格式
|
||||
- [okr +batch-create](lark-okr-batch-create.md) -- 批量创建多个 Objective / KR
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定用户的 OKR 周期,支持可选的时间范围过滤。
|
||||
列出指定用户的一页 OKR 周期,支持外部控制翻页和可选的时间范围后置过滤。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出用户的所有周期
|
||||
# 获取用户周期第一页 (默认页大小为 100 按时间倒序排列,一般不用翻页)
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx"
|
||||
|
||||
# 获取下一页
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100 --page-token "7000000000000000002"
|
||||
|
||||
# 使用特定的用户 ID 类型列出周期
|
||||
lark-cli okr +cycle-list --user-id "xxx" --user-id-type user_id
|
||||
|
||||
# 列出时间范围内的周期(例如 2025-01 到 2025-06)
|
||||
# 列出当前返回页中与时间范围重叠的周期(例如 2025-01 到 2025-06)
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --time-range "2025-01--2025-06"
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
@@ -26,7 +29,9 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|------------------|----|-----------|------------------------------------------------------------------|
|
||||
| `--user-id` | 是 | — | OKR 所有者的用户 ID |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--time-range` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
|
||||
| `--time-range` | 否 | — | 后置筛选条件:先按 `--page-size`/`--page-token` 请求一页,再在本地保留与该时间范围重叠的周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。 |
|
||||
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
|
||||
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
@@ -34,8 +39,11 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|
||||
1. 获取目标用户的 `open_id`(或其他 ID 类型)。如果用户说"我的 OKR 周期",先通过 `lark-cli contact +get-user` 获取当前用户的
|
||||
ID。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx"`,可选择使用 `--time-range`。
|
||||
3. 报告结果:找到的周期数量、每个周期的 ID、开始/结束时间和状态。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100`,可选择使用 `--time-range`。
|
||||
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页。
|
||||
4. 报告结果:每个周期的 ID、开始/结束时间和状态。
|
||||
|
||||
`--time-range` 是后置筛选条件,不会改变服务端分页窗口。也就是说,命令会先获取指定页,再过滤该页中的周期;如果需要完整时间范围结果,需要按 `has_more`/`page_token` 逐页拉取并合并。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -51,7 +59,8 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
"cycle_status": "normal"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"has_more": true,
|
||||
"page_token": "7000000000000000002",
|
||||
"current_active_cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
@@ -66,6 +75,7 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
在这个周期信息中,这些字段值得关注:
|
||||
|
||||
- `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
|
||||
- `has_more` 和 `page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
|
||||
- `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始,2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而
|
||||
"2025-01-01开始,2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。
|
||||
|
||||
@@ -50,6 +50,7 @@ Category (分类): Objective 的分组标签
|
||||
|
||||
- **当前周期**: 指周期的 start_time/end_time
|
||||
指周期的 start_time / end_time 所在的时间段与当前时间重叠的周期(即: start_time <= 当前时间 且 end_time >= 当前时间)。 注意:时间重叠是判断当前周期的首要且必须的硬性条件,绝对不能仅仅根据 cycle_status == 1 去判断。 如果有多个符合时间重叠标准的周期,再在这些包含当前时间的周期中过滤,保留周期状态为 default (0) 或 normal (1) 的周期。如果仍然有多个,则选择其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
|
||||
- 如果用户没有提及,那么当前周期一般不考虑年度周期(起止时间从 01-01 至 12-31 的周期)
|
||||
- **所有者**: 绝大多数所有者都是用户,少部分租户启用了“团队OKR”功能,所有者可能是部门。用户身份下,只能编辑所有者为当前用户的
|
||||
OKR。
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ lark-cli okr +indicator-update \
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标 ID 或 KR ID。
|
||||
2. 如需查看当前指标值,使用 `objective.indicators list` 或 `key_result.indicators list` 查询。
|
||||
3. 执行 `+indicator-update` 指定层级、ID 和新值。
|
||||
若当前量化指标没有 start_value/current_value/target_value/unit 这些字段,代表当前量化指标为未设置的默认初始进度。
|
||||
3. 执行 `+indicator-update` 指定层级、ID 和新值。
|
||||
使用 +indicator-update 为默认初始进度设置当前值会将该量化指标配置为默认的百分比模式。若用户不希望将指标设置为百分比,请使用原生 API 详细设置,参考 [lark-okr-indicators.md](lark-okr-indicators.md)
|
||||
4. 命令自动查询指标 ID 并更新当前值。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -40,11 +40,11 @@ lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags]
|
||||
```bash
|
||||
# 获取目标的量化指标
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7652569715131075772
|
||||
--objective-id 7000000000000000001
|
||||
|
||||
# 指定用户 ID 类型
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7652569715131075772 \
|
||||
--objective-id 7000000000000000001 \
|
||||
--user-id-type "user_id"
|
||||
```
|
||||
|
||||
@@ -60,6 +60,63 @@ lark-cli okr objective.indicators list \
|
||||
|
||||
返回 `indicator` 字段,包含该目标的量化指标详情。
|
||||
|
||||
示例返回值:
|
||||
有进度时:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"indicator": {
|
||||
"create_time": "1782835200000", // 创建时间
|
||||
"current_value": 60, // 当前值
|
||||
"current_value_calculate_type": 0, // 当前值计算方式 0(手动更新)|2(按KR计算)|3(按拆解计算)。 仅当此处为 0 时,允许使用 patch API 更新当前值
|
||||
"entity_id": "7000000000000000001",// 指标挂载的 Objective/KR id
|
||||
"entity_type": 2, // 指标挂载在 Objective还是KR 上 2(Objective)|3(KR)
|
||||
"id": "7000000000000000002", // 指标本身的 ID
|
||||
"indicator_status": 0, // 指标状态 -1(未定义)|0(正常)|1(有风险)|2(延期)
|
||||
"owner": { // 指标归属的用户
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"start_value": 0, // 起始值, 默认0
|
||||
"status_calculate_type": 0, // 状态计算方式
|
||||
"target_value": 100, // 目标值, 默认 100
|
||||
"unit": { // 指标单位,默认是公共的百分比
|
||||
"unit_type": 0, // 单位类型 0(公共)|1(自定义)
|
||||
"unit_value": "PERCENT" // 单位名
|
||||
},
|
||||
"update_time": "1782835200000" // 更新时间
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
默认初始进度:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"indicator": {
|
||||
"create_time": "1782835200000",
|
||||
"entity_id": "7000000000000000001",
|
||||
"entity_type": 2,
|
||||
"id": "7000000000000000002",
|
||||
"indicator_status": -1,
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"status_calculate_type": 0,
|
||||
"update_time": "1782835200000"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认初始进度不携带 start_value/current_value/target_value/unit 等信息,若直接设置当前值,则使用百分比作为默认单位。
|
||||
由于默认单位为百分比,当一定要计算数值时,可以视作 0%,但是向用户汇报默认初始进度时,应当明确对应的 O/KR 未设置进度这一点,以和真正的 0% 区别开。
|
||||
|
||||
---
|
||||
|
||||
## 二、查询关键结果的量化指标
|
||||
@@ -187,12 +244,7 @@ lark-cli okr indicators patch \
|
||||
```bash
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data '{
|
||||
"current_value": 65.0,
|
||||
"current_value_calculate_type": 0,
|
||||
"indicator_status": 1,
|
||||
"status_calculate_type": 0
|
||||
}'
|
||||
--data '{"current_value":65.0,"current_value_calculate_type":0,"indicator_status":1,"status_calculate_type":0}'
|
||||
```
|
||||
|
||||
4. **验证更新结果**
|
||||
@@ -210,10 +262,7 @@ lark-cli okr key_result.indicators list --key-result-id 7652569715131075780
|
||||
# 2. 更新目标值和单位
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id 7652569715131075781 \
|
||||
--data '{
|
||||
"target_value": 500,
|
||||
"unit": {"unit_type": 0, "unit_value": "YUAN"}
|
||||
}'
|
||||
--data '{"target_value":500,"unit":{"unit_type":0,"unit_value":"YUAN"}}'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
获取目标(Objective)或关键结果(Key Result)的所有进展记录列表。
|
||||
获取目标(Objective)或关键结果(Key Result)的一页进展记录列表,支持外部控制翻页。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 获取目标的所有进展记录
|
||||
# 获取目标进展记录第一页 (默认页大小为 100,一般不用翻页)
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective
|
||||
|
||||
# 获取关键结果的所有进展记录
|
||||
# 获取下一页进展记录
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective \
|
||||
--page-size 100 \
|
||||
--page-token "7000000000000000002"
|
||||
|
||||
# 获取关键结果进展记录第一页
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 9876543210987654321 \
|
||||
--target-type key_result
|
||||
@@ -26,14 +33,17 @@ lark-cli okr +progress-list \
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`department_id` \| `open_department_id` |
|
||||
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
|
||||
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective`。
|
||||
3. 获取该目标或关键结果下的所有进展记录列表。
|
||||
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective --page-size 100`。
|
||||
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页。
|
||||
4. 获取该目标或关键结果下的进展记录列表。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -41,7 +51,7 @@ lark-cli okr +progress-list \
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": [
|
||||
"progress_list": [
|
||||
{
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
@@ -52,13 +62,15 @@ lark-cli okr +progress-list \
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
"has_more": true,
|
||||
"page_token": "7000000000000000002"
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `progress` — 进展记录数组
|
||||
- `progress_list` — 进展记录数组
|
||||
- `has_more` 和 `page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
@@ -66,7 +78,7 @@ lark-cli okr +progress-list \
|
||||
|
||||
| 命令 | 用途 | API 版本 |
|
||||
|------------------|------------------------------------|----------|
|
||||
| `+progress-list` | 获取某个目标/关键结果的所有进展记录 | v2 |
|
||||
| `+progress-list` | 分页获取某个目标/关键结果的进展记录 | v2 |
|
||||
| `+progress-get` | 根据进展记录 ID 获取单条记录 | v1 |
|
||||
|
||||
`+progress-list` 返回的 `progress_list` 数组中每条记录的结构与 `+progress-get` 返回的 `progress` 结构相同。
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
所有 vc 命令默认使用 `--as user`。`+search` 和 `meeting get` 也支持 `--as bot`。
|
||||
所有 vc 命令默认使用 `--as user`。`meeting get`、`+detail`、`+recording`、`+notes` 也支持 `--as bot`(bot 只能访问 bot 有权限的会议、录制和纪要)。`+search` 仅支持 user。
|
||||
|
||||
```bash
|
||||
# BAD — 查昨天的会议用 calendar,会漏掉即时会议
|
||||
|
||||
@@ -40,9 +40,9 @@ lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
每次只能指定一种输入方式。同时传入会报错。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
### 2. 身份支持
|
||||
|
||||
该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。user token 只能查自己有权限的录制。
|
||||
`--meeting-ids` 和 `--calendar-event-ids` 两种模式都支持 `--as user` 和 `--as bot`。user token 只能查自己有权限的录制;bot 使用 tenant_access_token,只能查 bot 有权限的录制。
|
||||
|
||||
### 3. 批量上限
|
||||
|
||||
|
||||
@@ -22,21 +22,22 @@ metadata:
|
||||
|
||||
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`。
|
||||
|
||||
| 用户需求 | 行动 |
|
||||
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
|
||||
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
|
||||
| 用户需求 | 行动 |
|
||||
|-----------------------------------------|---------------------------------------------------------------------------------------------------|
|
||||
| 查看画板内容 / 导出图片 | [`+export --output-type preview`](references/lark-whiteboard-export.md) |
|
||||
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
|
||||
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
|
||||
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
|
||||
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
|
||||
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
|
||||
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
|
||||
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
|
||||
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
|
||||
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|---|---|
|
||||
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构。 |
|
||||
| Shortcut | 说明 |
|
||||
|---------------------------------------------------|---|
|
||||
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# whiteboard +query(查询画板)
|
||||
# whiteboard +export(导出画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
导出画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|----------------------|----|------------------------------------------------------------------------|
|
||||
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的读权限 |
|
||||
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`(SVG 矢量图)、`code`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
|
||||
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
|
||||
| `--output-type` | 是 | 输出格式:`preview`(预览图片)、`svg`(SVG 矢量图)、`source`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
|
||||
| `--output` | 否 | 输出路径。当 `--output-type preview` 时必填,推荐传入无后缀文件路径(如 `./preview`);当 `--output-type svg/source/raw` 时可选,不填则直接输出到终端 |
|
||||
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
|
||||
|
||||
## 输出格式
|
||||
|
||||
- `image`:预览图片
|
||||
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径,CLI 会按实际图片类型保存为 `./preview.png` 或 `./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`。
|
||||
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
|
||||
- `code`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `source`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
|
||||
|
||||
## 示例
|
||||
@@ -25,26 +25,26 @@
|
||||
### 示例 1:导出画板为预览图片
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
--output-type preview \
|
||||
--output ./preview
|
||||
```
|
||||
|
||||
### 示例 2:提取画板中的代码并直接输出
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as code
|
||||
--output-type source
|
||||
```
|
||||
|
||||
### 示例 3:导出画板为 SVG 矢量图
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as svg \
|
||||
--output-type svg \
|
||||
--output ./whiteboard.svg \
|
||||
--as user
|
||||
```
|
||||
@@ -52,9 +52,9 @@ lark-cli whiteboard +query \
|
||||
### 示例 4:导出画板原始节点结构到文件
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as raw \
|
||||
--output-type raw \
|
||||
--output ./nodes.json \
|
||||
--overwrite
|
||||
```
|
||||
@@ -26,12 +26,12 @@
|
||||
**Step 2:判断修改策略**
|
||||
|
||||
```
|
||||
+query --output_as code
|
||||
+export --output-type source
|
||||
├─ 返回 Mermaid/PlantUML 代码
|
||||
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
|
||||
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
|
||||
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
|
||||
│ │ → +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
|
||||
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
|
||||
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
|
||||
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
|
||||
└─ 用户有明确要求 → 以用户要求优先
|
||||
|
||||
@@ -25,9 +25,9 @@ SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层
|
||||
### 1. 导出当前画板 SVG
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token <TOKEN> \
|
||||
--output_as svg \
|
||||
--output-type svg \
|
||||
--output <dir>/original.svg \
|
||||
--as user
|
||||
```
|
||||
|
||||
@@ -54,8 +54,6 @@
|
||||
- 阴影:`<filter>` 里放 `<feDropShadow>` 或标准 drop/inner primitive 链 (`<feGaussianBlur in="SourceAlpha">` + `<feOffset>` + `<feFlood>` + `<feComposite>` + `<feMerge>`), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别
|
||||
- 渐变:`<linearGradient>` / `<radialGradient>` 在 `<defs>` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `<rect>` / `<circle>` / `<ellipse>` / `<polygon>` / `<path>`), 需要至少 2 个 `<stop>`, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可);
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **不支持的装饰特性**
|
||||
|
||||
**⚠️ [!IMPORTANT] 不支持的装饰特性**
|
||||
- `<pattern>` / `<clipPath>` / `<mask>` / 非阴影用途的 `<filter>` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `<feDropShadow>` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题**
|
||||
- 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox`
|
||||
|
||||
52
tests/cli_e2e/base/base_field_update_dryrun_test.go
Normal file
52
tests/cli_e2e/base/base_field_update_dryrun_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseFieldUpdateAutoNumberDryRun(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+field-update",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-id", "fld_x",
|
||||
"--json", `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
"--yes",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "编号", gjson.Get(out, "data.api.0.body.name").String(), out)
|
||||
require.Equal(t, "auto_number", gjson.Get(out, "data.api.0.body.type").String(), out)
|
||||
require.Equal(t, "created_time", gjson.Get(out, "data.api.0.body.style.rules.1.type").String(), out)
|
||||
require.Equal(t, "yyyyMM", gjson.Get(out, "data.api.0.body.style.rules.1.date_format").String(), out)
|
||||
require.Equal(t, int64(4), gjson.Get(out, "data.api.0.body.style.rules.3.length").Int(), out)
|
||||
require.False(t, gjson.Get(out, "data.api.0.body.property.auto_serial").Exists(), out)
|
||||
require.NotContains(t, out, "reformat_existing_records", out)
|
||||
require.NotContains(t, out, "/open-apis/bitable/v1/", out)
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateDryRunAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+field-update",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-id", "fld_x",
|
||||
"--json", `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`,
|
||||
"--yes",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "评分", gjson.Get(out, "data.api.0.body.name").String(), out)
|
||||
require.Equal(t, "rating", gjson.Get(out, "data.api.0.body.style.type").String(), out)
|
||||
require.Equal(t, int64(20), gjson.Get(out, "data.api.0.body.style.max").Int(), out)
|
||||
}
|
||||
188
tests/cli_e2e/base/base_record_list_dryrun_test.go
Normal file
188
tests/cli_e2e/base/base_record_list_dryrun_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseRecordListDryRunAcceptsFieldsAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--fields", `["Name","Age"]`,
|
||||
"--limit", "3",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&field_id=Age&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchDryRunAcceptsFieldsAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Alice",
|
||||
"--search-field", "Name",
|
||||
"--fields", `["Name","Age"]`,
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunAcceptsFieldNamesAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_1",
|
||||
"--field-names", "Name",
|
||||
"--field-names", "Age",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunTreatsNullProjectionAsOmitted(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.False(t, gjson.Get(out, "data.api.0.body.select_fields").Exists(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunUsesFlagProjectionWhenJSONProjectionIsNull(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
|
||||
"--field-id", "Name",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordListDryRunPreservesFieldNamesCSVSemantics(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-names", `"A,B",@Owner`,
|
||||
"--limit", "3",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=A%2CB&field_id=%40Owner&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordListDryRunTreatsLeadingAtFieldNameLiterally(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-names", "@Owner",
|
||||
"--limit", "3",
|
||||
)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=%40Owner&limit=3&offset=0", gjson.Get(result.Stdout, "data.api.0.url").String(), result.Stdout)
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchDryRunJSONConflictReportsActualParams(t *testing.T) {
|
||||
result := runBaseDryRun(t, 2,
|
||||
"base", "+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Equal(t, int64(2), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
|
||||
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
|
||||
require.Equal(t, "--field-names", gjson.Get(result.Stderr, "error.params.1.name").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "inside --json")
|
||||
require.Empty(t, result.Stdout)
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionDryRunKeepsActiveParamForFlagLikeFieldNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "canonical",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--field-id", "Cost--USD", "--field-id", "Cost--USD",
|
||||
},
|
||||
wantParam: "--field-id",
|
||||
},
|
||||
{
|
||||
name: "fields alias",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--fields", `["Cost--USD","Cost--USD"]`,
|
||||
},
|
||||
wantParam: "--fields",
|
||||
},
|
||||
{
|
||||
name: "field names alias",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--field-names", "Cost--USD", "--field-names", "Cost--USD",
|
||||
},
|
||||
wantParam: "--field-names",
|
||||
},
|
||||
{
|
||||
name: "json projection",
|
||||
args: []string{
|
||||
"base", "+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
wantParam: "--json",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := runBaseDryRun(t, 2, tc.args...)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
|
||||
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "duplicate field id")
|
||||
require.Empty(t, result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,21 @@ import (
|
||||
|
||||
const cleanupTimeout = 30 * time.Second
|
||||
|
||||
func runBaseDryRun(t *testing.T, wantExitCode int, args ...string) *clie2e.Result {
|
||||
t.Helper()
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
requestArgs := append([]string(nil), args...)
|
||||
requestArgs = append(requestArgs, "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: requestArgs, DefaultAs: "user"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, wantExitCode)
|
||||
return result
|
||||
}
|
||||
|
||||
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
|
||||
parentT.Helper()
|
||||
|
||||
|
||||
@@ -37,6 +37,29 @@ func TestMinutesApplyPermission_DryRun(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_DryRun_BotIdentity(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+apply-permission",
|
||||
"--minute-token", "obcnexampleminute",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply"), "dry-run should contain API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_InvalidPerm(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
50
tests/cli_e2e/minutes/minutes_detail_dryrun_test.go
Normal file
50
tests/cli_e2e/minutes/minutes_detail_dryrun_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMinutesDetailDryRun_BotIdentity pins that `minutes +detail` accepts
|
||||
// --as bot for both the metadata (GetMinuteArtifacts) and transcript
|
||||
// (GetMinuteTranscript) paths, which accept a tenant access token.
|
||||
func TestMinutesDetailDryRun_BotIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
setDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+detail",
|
||||
"--minute-tokens", "obcn1234567890",
|
||||
"--transcript",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Args, "--as")
|
||||
require.Contains(t, result.Args, "bot")
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}/artifacts", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"minutes", "+detail", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
@@ -35,6 +35,8 @@ func TestOKR_CycleListDryRun(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "ou_dryrun_test"), "dry-run should contain user-id, got: %s", output)
|
||||
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
|
||||
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_CycleListDryRun_WithTimeRange validates +cycle-list dry-run with --time-range flag.
|
||||
@@ -57,3 +59,26 @@ func TestOKR_CycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_CycleListDryRun_WithPagination validates +cycle-list dry-run with explicit pagination.
|
||||
func TestOKR_CycleListDryRun_WithPagination(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-list",
|
||||
"--user-id", "ou_dryrun_test",
|
||||
"--page-size", "20",
|
||||
"--page-token", "next_page",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, int64(20), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=20, got: %s", output)
|
||||
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
|
||||
}
|
||||
|
||||
@@ -298,6 +298,8 @@ func TestOKR_ProgressListDryRun_Objective(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/123456789/progresses"), "dry-run should contain objective API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
|
||||
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
|
||||
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ProgressListDryRun_KeyResult validates +progress-list dry-run for key_result.
|
||||
@@ -321,3 +323,27 @@ func TestOKR_ProgressListDryRun_KeyResult(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/987654321/progresses"), "dry-run should contain key_result API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ProgressListDryRun_WithPagination validates +progress-list dry-run with explicit pagination.
|
||||
func TestOKR_ProgressListDryRun_WithPagination(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "25",
|
||||
"--page-token", "next_page",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, int64(25), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=25, got: %s", output)
|
||||
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,62 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
|
||||
// --- Dry-run E2E tests for +create, +batch-create, +reorder, +weight ---
|
||||
|
||||
// TestOKR_CreateDryRun_Objective validates +create dry-run for objective creation.
|
||||
func TestOKR_CreateDryRun_Objective(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123456",
|
||||
"--content", `{"text":"Objective 1","mention":["ou_123"]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
|
||||
assert.Equal(t, "/open-apis/okr/v2/cycles/123456/objectives", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain objective API path")
|
||||
assert.Equal(t, "123456", gjson.Get(output, "data.api.0.params.cycle_id").String(), "dry-run should contain cycle-id query param")
|
||||
assert.Equal(t, "open_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain default user-id-type")
|
||||
assert.Equal(t, "Objective 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain serialized content text")
|
||||
assert.Equal(t, "ou_123", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(), "dry-run should contain serialized mention")
|
||||
}
|
||||
|
||||
// TestOKR_CreateDryRun_KeyResult validates +create dry-run for key-result creation.
|
||||
func TestOKR_CreateDryRun_KeyResult(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "789",
|
||||
"--style", "richtext",
|
||||
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"KR 1"}}]}}]}`,
|
||||
"--user-id-type", "user_id",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
|
||||
assert.Equal(t, "/open-apis/okr/v2/objectives/789/key_results", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain key-result API path")
|
||||
assert.Equal(t, "789", gjson.Get(output, "data.api.0.params.objective_id").String(), "dry-run should contain objective-id query param")
|
||||
assert.Equal(t, "user_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain explicit user-id-type")
|
||||
assert.Equal(t, "KR 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain rich-text body")
|
||||
}
|
||||
|
||||
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
|
||||
func TestOKR_BatchCreateDryRun(t *testing.T) {
|
||||
@@ -383,6 +438,44 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
}
|
||||
}
|
||||
|
||||
func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suffix string) liveTestCreated {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", cycleID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create live objective")
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectiveID := gjson.Get(result.Stdout, "data.objective_id").String()
|
||||
require.NotEmpty(t, objectiveID, "objective_id should not be empty")
|
||||
|
||||
return liveTestCreated{ObjectiveID: objectiveID}
|
||||
}
|
||||
|
||||
func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string, suffix string) string {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", objectiveID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create live key result")
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
keyResultID := gjson.Get(result.Stdout, "data.key_result_id").String()
|
||||
require.NotEmpty(t, keyResultID, "key_result_id should not be empty")
|
||||
|
||||
return keyResultID
|
||||
}
|
||||
|
||||
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
|
||||
func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
@@ -432,6 +525,87 @@ func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
|
||||
}
|
||||
|
||||
// TestOKR_CreateLive_Objective validates +create objective with real API calls: create, verify, cleanup.
|
||||
func TestOKR_CreateLive_Objective(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
created := createLiveObjective(t, ctx, cycleID, suffix)
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, []liveTestCreated{created})
|
||||
})
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
found := false
|
||||
for _, obj := range objectives {
|
||||
if obj.Get("id").String() == created.ObjectiveID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "created objective should be visible in cycle detail")
|
||||
}
|
||||
|
||||
// TestOKR_CreateLive_KeyResultUnderExistingObjective validates +create key-result under an existing objective.
|
||||
func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
created := createLiveObjective(t, ctx, cycleID, suffix)
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, []liveTestCreated{created})
|
||||
})
|
||||
|
||||
keyResultID := createLiveKeyResult(t, ctx, created.ObjectiveID, suffix)
|
||||
created.KRIDs = append(created.KRIDs, keyResultID)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
foundObjective := false
|
||||
foundKR := false
|
||||
for _, obj := range objectives {
|
||||
if obj.Get("id").String() != created.ObjectiveID {
|
||||
continue
|
||||
}
|
||||
foundObjective = true
|
||||
for _, kr := range obj.Get("key_results").Array() {
|
||||
if kr.Get("id").String() == keyResultID {
|
||||
foundKR = true
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
assert.True(t, foundObjective, "created objective should be visible in cycle detail")
|
||||
assert.True(t, foundKR, "created key result should be visible under the created objective")
|
||||
}
|
||||
|
||||
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
|
||||
func TestOKR_ReorderLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
50
tests/cli_e2e/vc/vc_detail_dryrun_test.go
Normal file
50
tests/cli_e2e/vc/vc_detail_dryrun_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVCDetailDryRun_BotIdentity pins that `vc +detail` accepts --as bot and
|
||||
// previews the meeting.get + recording API round-trip (GetMeetingByID /
|
||||
// GetRecordingByMeetingID both accept a tenant access token).
|
||||
func TestVCDetailDryRun_BotIdentity(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+detail",
|
||||
"--meeting-ids", "7628568141510692381",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Args, "--as")
|
||||
require.Contains(t, result.Args, "bot")
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "7628568141510692381", clie2e.DryRunGet(out, "meeting_ids.0").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"vc", "+detail", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
70
tests/cli_e2e/vc/vc_recording_dryrun_test.go
Normal file
70
tests/cli_e2e/vc/vc_recording_dryrun_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVCRecordingDryRun_BotIdentity pins that `vc +recording --meeting-ids`
|
||||
// accepts --as bot (GetRecordingByMeetingID accepts a tenant access token).
|
||||
func TestVCRecordingDryRun_BotIdentity(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+recording",
|
||||
"--meeting-ids", "7628568141510692381",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"vc", "+recording", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
|
||||
// TestVCRecordingDryRun_BotIdentity_CalendarEventIDs pins that the
|
||||
// calendar-event-ids path also flows under --as bot: a bot has a primary
|
||||
// calendar, so the primary -> mget_instance_relation_info -> recording chain
|
||||
// is previewed without a validation error.
|
||||
func TestVCRecordingDryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
|
||||
setVCDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+recording",
|
||||
"--calendar-event-ids", "evt_001",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Contains(t, out, "mget_instance_relation_info", "stdout:\n%s", out)
|
||||
require.Contains(t, out, "recording", "stdout:\n%s", out)
|
||||
}
|
||||
191
tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go
Normal file
191
tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWhiteboardExportDryRun_RequestShapes(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMethod string
|
||||
wantSuffix string
|
||||
wantBody map[string]string
|
||||
}{
|
||||
{
|
||||
name: "preview",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunPreview",
|
||||
"--output-type", "preview",
|
||||
"--output", "preview",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "svg",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunSvg",
|
||||
"--output-type", "svg",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "POST",
|
||||
wantSuffix: "/export",
|
||||
wantBody: map[string]string{
|
||||
"export_type": "svg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunSource",
|
||||
"--output-type", "source",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/nodes",
|
||||
},
|
||||
{
|
||||
name: "raw",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunRaw",
|
||||
"--output-type", "raw",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
|
||||
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != tt.wantMethod {
|
||||
t.Fatalf("method=%q, want %q\nstdout:\n%s", got, tt.wantMethod, out)
|
||||
}
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, tt.wantSuffix) {
|
||||
t.Fatalf("url=%q, want board whiteboard URL ending %q\nstdout:\n%s", gotURL, tt.wantSuffix, out)
|
||||
}
|
||||
for key, want := range tt.wantBody {
|
||||
if got := clie2e.DryRunGet(out, "api.0.body."+key).String(); got != want {
|
||||
t.Fatalf("body.%s=%q, want %q\nstdout:\n%s", key, got, want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryDryRun_LegacySmoke(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+query",
|
||||
"--whiteboard-token", "wbcnDryRunLegacy",
|
||||
"--output_as", "image",
|
||||
"--output", "preview",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/download_as_image") {
|
||||
t.Fatalf("url=%q, want preview download\nstdout:\n%s", gotURL, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardExportSelectorRequiredBeforeAuth(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("export requires output-type", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnMissingSelector",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + "\n" + result.Stderr
|
||||
if got := gjson.Get(output, "error.type").String(); got != "validation" {
|
||||
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output-type") {
|
||||
t.Fatalf("error.message=%q, want output-type\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy query requires output_as", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+query",
|
||||
"--whiteboard-token", "wbcnMissingSelector",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + "\n" + result.Stderr
|
||||
if got := gjson.Get(output, "error.type").String(); got != "validation" {
|
||||
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output_as") {
|
||||
t.Fatalf("error.message=%q, want output_as\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setWhiteboardDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "whiteboard_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "whiteboard_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
71
tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go
Normal file
71
tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWhiteboardExportPreview_JPEGLiveWorkflow(t *testing.T) {
|
||||
token := os.Getenv("LARK_WHITEBOARD_E2E_TOKEN")
|
||||
if token == "" {
|
||||
t.Skip("skipped: LARK_WHITEBOARD_E2E_TOKEN not set")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", token,
|
||||
"--output-type", "preview",
|
||||
"--output", "preview",
|
||||
"--overwrite",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Format: "json",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
saved := filepath.Join(workDir, "preview.jpg")
|
||||
data, err := os.ReadFile(saved)
|
||||
require.NoError(t, err, "expected JPEG preview at %s\nstdout:\n%s\nstderr:\n%s", saved, result.Stdout, result.Stderr)
|
||||
require.True(t, isJPEG(data), "expected JPEG data in %s", saved)
|
||||
|
||||
mismatch, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", token,
|
||||
"--output-type", "preview",
|
||||
"--output", "preview.png",
|
||||
"--overwrite",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Format: "json",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
mismatch.AssertExitCode(t, 2)
|
||||
if !strings.Contains(mismatch.Stdout+"\n"+mismatch.Stderr, "failed_precondition") {
|
||||
t.Fatalf("expected failed_precondition for mismatched extension\nstdout:\n%s\nstderr:\n%s", mismatch.Stdout, mismatch.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func isJPEG(data []byte) bool {
|
||||
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
|
||||
}
|
||||
Reference in New Issue
Block a user