mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
4 Commits
fix/format
...
v1.0.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34ee5bef9 | ||
|
|
052e2112bf | ||
|
|
76a834e928 | ||
|
|
20761fa56a |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
@@ -328,6 +345,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -151,6 +151,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err should not mention array: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Fatalf("stdout=%q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -259,7 +340,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
||||
@@ -277,7 +358,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
||||
@@ -1203,7 +1284,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate == nil {
|
||||
t.Fatal("expected validate hook")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("create validate err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = common.ParseJSON([]byte(raw), &body)
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
return parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
}
|
||||
|
||||
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
|
||||
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
|
||||
@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
|
||||
}
|
||||
|
||||
func validateViewCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseObjectList(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func validateViewJSONObject(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONValue(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func executeViewList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetGroup,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetSort,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
|
||||
@@ -74,6 +74,7 @@ var commonEventTypes = []string{
|
||||
"approval.approval.updated",
|
||||
"application.application.visibility.added_v6",
|
||||
"task.task.update_tenant_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"task.task.comment_updated_v1",
|
||||
"drive.notice.comment_add_v1",
|
||||
}
|
||||
|
||||
333
shortcuts/sheets/sheet_dropdown.go
Normal file
333
shortcuts/sheets/sheet_dropdown.go
Normal file
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func dataValidationBasePath(token string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
|
||||
validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func dataValidationSheetPath(token, sheetID string) string {
|
||||
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
|
||||
var typed []string
|
||||
if err := json.Unmarshal([]byte(value), &typed); err != nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
|
||||
}
|
||||
if typed == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
|
||||
}
|
||||
arr := make([]interface{}, len(typed))
|
||||
for i, s := range typed {
|
||||
arr[i] = s
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, common.FlagErrorf("--ranges must not be empty")
|
||||
}
|
||||
for i, r := range ranges {
|
||||
s, _ := r.(string)
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
|
||||
}
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(condValues) == 0 {
|
||||
return nil, common.FlagErrorf("--condition-values must not be empty")
|
||||
}
|
||||
|
||||
dv := map[string]interface{}{
|
||||
"conditionValues": condValues,
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("multiple") {
|
||||
opts["multipleValues"] = runtime.Bool("multiple")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("highlight") {
|
||||
opts["highlightValidData"] = runtime.Bool("highlight")
|
||||
}
|
||||
if runtime.Str("colors") != "" {
|
||||
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) != len(condValues) {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
|
||||
}
|
||||
opts["colors"] = colors
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
dv["options"] = opts
|
||||
}
|
||||
|
||||
return dv, nil
|
||||
}
|
||||
|
||||
// SheetSetDropdown sets dropdown list validation on a range.
|
||||
var SheetSetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-dropdown",
|
||||
Description: "Set dropdown list on a cell range",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateDropdown updates dropdown list settings for given ranges.
|
||||
var SheetUpdateDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dropdown",
|
||||
Description: "Update dropdown list settings",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateRangesFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
|
||||
Body(map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetDropdown queries dropdown list settings for a range.
|
||||
var SheetGetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-dropdown",
|
||||
Description: "Get dropdown list settings for a range",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
|
||||
Set("token", token).Set("range", runtime.Str("range"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
}, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
|
||||
var SheetDeleteDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dropdown",
|
||||
Description: "Delete dropdown list from cell ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateRangesFlag(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
552
shortcuts/sheets/sheet_dropdown_test.go
Normal file
552
shortcuts/sheets/sheet_dropdown_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── SetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": "not-json",
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mixed types", `["ok", 1, null]`},
|
||||
{"all numbers", `[1, 2, 3]`},
|
||||
{"null literal", `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": tc.input,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
|
||||
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "bad-json",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "A2:A100", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `[]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
|
||||
"colors": `["#FF0000"]`,
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors length") {
|
||||
t.Fatalf("expected length mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": true, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"dataValidationType":"list"`) {
|
||||
t.Fatalf("DryRun missing dataValidationType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
|
||||
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
dv, _ := body["dataValidation"].(map[string]interface{})
|
||||
opts, _ := dv["options"].(map[string]interface{})
|
||||
if opts["multipleValues"] != true {
|
||||
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
|
||||
}
|
||||
if opts["highlightValidData"] != true {
|
||||
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": "not-json", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `[]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "{not-array}",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `sheet1`) {
|
||||
t.Fatalf("DryRun missing sheet_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["new1","new2"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["opt1"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"dataValidationType": "list",
|
||||
"conditionValues": []interface{}{"opt1", "opt2"},
|
||||
"ranges": []interface{}{"s1!A2:A100"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "dataValidations") {
|
||||
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidationRanges`) {
|
||||
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"rangeResults": []interface{}{
|
||||
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "rangeResults") {
|
||||
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
dvRanges, _ := body["dataValidationRanges"].([]interface{})
|
||||
if len(dvRanges) != 2 {
|
||||
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suppress unused import for bytes in case the test helpers already import it
|
||||
var _ = (*bytes.Buffer)(nil)
|
||||
@@ -36,5 +36,9 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
SetAncestorTask,
|
||||
CommentTask,
|
||||
CompleteTask,
|
||||
ReopenTask,
|
||||
@@ -230,7 +231,11 @@ func Shortcuts() []common.Shortcut {
|
||||
FollowersTask,
|
||||
ReminderTask,
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
AddTaskToTasklist,
|
||||
MembersTasklist,
|
||||
}
|
||||
|
||||
155
shortcuts/task/task_get_related_tasks.go
Normal file
155
shortcuts/task/task_get_related_tasks.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
relatedTasksDefaultPageLimit = 20
|
||||
relatedTasksMaxPageLimit = 40
|
||||
relatedTasksPageSize = 100
|
||||
)
|
||||
|
||||
var GetRelatedTasks = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+get-related-tasks",
|
||||
Description: "list tasks related to me",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
|
||||
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
|
||||
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
params["completed"] = false
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/task_v2/list_related_task").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
queryParams.Set("completed", "false")
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = relatedTasksDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
if pageLimit > relatedTasksMaxPageLimit {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
|
||||
var allItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "list related tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", lastPageToken)
|
||||
}
|
||||
|
||||
userOpenID := runtime.UserOpenId()
|
||||
filtered := make([]map[string]interface{}, 0, len(allItems))
|
||||
for _, item := range allItems {
|
||||
task, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if runtime.Bool("created-by-me") {
|
||||
creator, _ := task["creator"].(map[string]interface{})
|
||||
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, outputRelatedTask(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": filtered,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
|
||||
if len(filtered) == 0 {
|
||||
fmt.Fprintln(w, "No related tasks found.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
|
||||
members, _ := task["members"].([]interface{})
|
||||
for _, member := range members {
|
||||
memberObj, _ := member.(map[string]interface{})
|
||||
role, _ := memberObj["role"].(string)
|
||||
id, _ := memberObj["id"].(string)
|
||||
if strings.EqualFold(role, "follower") && id == userOpenID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
207
shortcuts/task/task_get_related_tasks_test.go
Normal file
207
shortcuts/task/task_get_related_tasks_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestTaskFollowedBy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
userOpenID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
map[string]interface{}{"id": "ou_2", "role": "follower"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_3",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := taskFollowedBy(tt.task, tt.userOpenID)
|
||||
if got != tt.want {
|
||||
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with page token and incomplete filter",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("include-complete", "false")
|
||||
_ = cmd.Flags().Set("page-token", "pt_001")
|
||||
},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
|
||||
},
|
||||
{
|
||||
name: "default query params",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Bool("include-complete", true, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
out := GetRelatedTasks.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json created by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "done",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123",
|
||||
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
|
||||
},
|
||||
{
|
||||
name: "pretty pagination followed by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_2",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
"summary": "Task One",
|
||||
"url": "https://example.com/task-1",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "page_token=pt_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-2",
|
||||
"summary": "Task Two",
|
||||
"url": "https://example.com/task-2",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Task One", "Task Two"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := GetRelatedTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
247
shortcuts/task/task_query_helpers.go
Normal file
247
shortcuts/task/task_query_helpers.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func splitAndTrimCSV(input string) []string {
|
||||
parts := strings.Split(input, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startMillis, endMillis string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startMillis = startSec + "000"
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endMillis = endSec + "000"
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startMillis, endMillis, nil
|
||||
}
|
||||
|
||||
func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func formatTaskDateTimeMillis(msStr string) string {
|
||||
if msStr == "" || msStr == "0" {
|
||||
return ""
|
||||
}
|
||||
ms, err := strconv.ParseInt(msStr, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format(time.DateTime)
|
||||
}
|
||||
|
||||
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
|
||||
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
|
||||
out["updated_at"] = updated
|
||||
}
|
||||
}
|
||||
if dueObj, ok := task["due"].(map[string]interface{}); ok {
|
||||
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
|
||||
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
|
||||
out["due_at"] = dueAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"description": task["description"],
|
||||
"status": task["status"],
|
||||
"source": task["source"],
|
||||
"mode": task["mode"],
|
||||
"subtask_count": task["subtask_count"],
|
||||
"tasklists": task["tasklists"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if creator, ok := task["creator"].(map[string]interface{}); ok {
|
||||
out["creator"] = creator
|
||||
}
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
out["members"] = members
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
|
||||
timeRange := map[string]interface{}{}
|
||||
if start != "" {
|
||||
timeRange["start_time"] = start
|
||||
}
|
||||
if end != "" {
|
||||
timeRange["end_time"] = end
|
||||
}
|
||||
if len(timeRange) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{key: timeRange}
|
||||
}
|
||||
|
||||
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
|
||||
if strings.TrimSpace(query) != "" {
|
||||
return nil
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
return nil
|
||||
}
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
|
||||
}
|
||||
|
||||
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
|
||||
var b strings.Builder
|
||||
for i, item := range items {
|
||||
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
|
||||
if status, _ := item["status"].(string); status != "" {
|
||||
fmt.Fprintf(&b, " Status: %s\n", status)
|
||||
}
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(&b, " Created: %s\n", created)
|
||||
}
|
||||
if completed, _ := item["completed_at"].(string); completed != "" {
|
||||
fmt.Fprintf(&b, " Completed: %s\n", completed)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(&b, " URL: %s\n", urlVal)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if hasMore && pageToken != "" {
|
||||
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
286
shortcuts/task/task_query_helpers_test.go
Normal file
286
shortcuts/task/task_query_helpers_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitAndTrimCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
|
||||
{name: "empty input", input: "", want: []string{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := splitAndTrimCSV(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "with timestamps and due",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "summary",
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"created_at": "1775174400000",
|
||||
"due": map[string]interface{}{
|
||||
"timestamp": "1775174400000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with completed and updated",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "done",
|
||||
"url": "https://example.com/task-456",
|
||||
"completed_at": "1775174400000",
|
||||
"updated_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := outputTaskSummary(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected summary output: %#v", got)
|
||||
}
|
||||
if got["url"] == "" {
|
||||
t.Fatalf("expected url in output, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
for _, tt := range timeTests {
|
||||
t.Run("parse:"+tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeMillis(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if tt.wantStart == "" && start != "" {
|
||||
t.Fatalf("start = %q, want empty", start)
|
||||
}
|
||||
if tt.wantEnd == "" && end != "" {
|
||||
t.Fatalf("end = %q, want empty", end)
|
||||
}
|
||||
if tt.wantStart == "non-empty" && start == "" {
|
||||
t.Fatalf("start should not be empty")
|
||||
}
|
||||
if tt.wantEnd == "non-empty" && end == "" {
|
||||
t.Fatalf("end should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
filterTests := []struct {
|
||||
name string
|
||||
query string
|
||||
filter map[string]interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
|
||||
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
|
||||
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
|
||||
}
|
||||
for _, tt := range filterTests {
|
||||
t.Run("filter:"+tt.name, func(t *testing.T) {
|
||||
err := requireSearchFilter(tt.query, tt.filter, "search")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
|
||||
outputTests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "todo",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"creator": map[string]interface{}{"id": "ou_1"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
|
||||
"created_at": "1775174400000",
|
||||
"completed_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "Minimal",
|
||||
"url": "https://example.com/task-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range outputTests {
|
||||
t.Run("output:"+tt.name, func(t *testing.T) {
|
||||
got := outputRelatedTask(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected related task output: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rangeTests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "empty range", start: "", end: "", wantNil: true},
|
||||
{name: "full range", start: "1", end: "2", wantNil: false},
|
||||
}
|
||||
for _, tt := range rangeTests {
|
||||
t.Run("range:"+tt.name, func(t *testing.T) {
|
||||
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Fatalf("expected nil, got %#v", got)
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Fatalf("expected range filter, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []map[string]interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "includes next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
|
||||
},
|
||||
hasMore: true,
|
||||
pageToken: "pt_123",
|
||||
wantParts: []string{"Related Task", "Next page token: pt_123"},
|
||||
},
|
||||
{
|
||||
name: "without next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-456", "summary": "Another Task"},
|
||||
},
|
||||
hasMore: false,
|
||||
pageToken: "",
|
||||
wantParts: []string{"Another Task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range timeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeRFC3339(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
|
||||
}
|
||||
if tt.wantStart == "rfc3339" {
|
||||
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
|
||||
t.Fatalf("expected RFC3339 start, got %q", start)
|
||||
}
|
||||
} else if start != tt.wantStart {
|
||||
t.Fatalf("unexpected start: %q", start)
|
||||
}
|
||||
if tt.wantEnd == "rfc3339" {
|
||||
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
|
||||
t.Fatalf("expected RFC3339 end, got %q", end)
|
||||
}
|
||||
} else if end != tt.wantEnd {
|
||||
t.Fatalf("unexpected end: %q", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
222
shortcuts/task/task_search.go
Normal file
222
shortcuts/task/task_search.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
taskSearchDefaultPageLimit = 20
|
||||
taskSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+search",
|
||||
Description: "search tasks",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
|
||||
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
|
||||
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
|
||||
{Name: "follower", Desc: "follower open_ids, comma-separated"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTaskSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = taskSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > taskSearchMaxPageLimit {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
taskID, _ := itemMap["id"].(string)
|
||||
if taskID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := getTaskDetail(runtime, taskID)
|
||||
if err != nil {
|
||||
metaData, _ := itemMap["meta_data"].(map[string]interface{})
|
||||
appLink, _ := metaData["app_link"].(string)
|
||||
enriched = append(enriched, map[string]interface{}{
|
||||
"guid": taskID,
|
||||
"url": truncateTaskURL(appLink),
|
||||
})
|
||||
continue
|
||||
}
|
||||
enriched = append(enriched, outputTaskSummary(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": enriched,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
return
|
||||
}
|
||||
for i, item := range enriched {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(w, " Created: %s\n", created)
|
||||
}
|
||||
if dueAt, _ := item["due_at"].(string); dueAt != "" {
|
||||
fmt.Fprintf(w, " Due: %s\n", dueAt)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["creator_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
|
||||
filter["assignee_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
|
||||
filter["follower_ids"] = ids
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("completed") {
|
||||
filter["is_completed"] = runtime.Bool("completed")
|
||||
}
|
||||
if dueRange := runtime.Str("due"); dueRange != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(dueRange)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
|
||||
}
|
||||
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
|
||||
mergeIntoFilter(filter, dueFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
300
shortcuts/task/task_search_test.go
Normal file
300
shortcuts/task/task_search_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTaskSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "query creator due and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "release")
|
||||
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
|
||||
_ = cmd.Flags().Set("completed", "true")
|
||||
_ = cmd.Flags().Set("due", "-1d,+1d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_123")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := dueTime["start_time"].(string)
|
||||
endTime, _ := dueTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected due_time: %#v", dueTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "assignee follower and incomplete",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("assignee", "ou_assignee")
|
||||
_ = cmd.Flags().Set("follower", "ou_follower")
|
||||
_ = cmd.Flags().Set("completed", "false")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
if filter["is_completed"] != false {
|
||||
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTaskSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("due", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTask.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
|
||||
},
|
||||
{
|
||||
name: "fallback to app link",
|
||||
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-999",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasks found."},
|
||||
},
|
||||
{
|
||||
name: "pretty with next page token",
|
||||
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-321",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTask
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
shortcuts/task/task_set_ancestor.go
Normal file
84
shortcuts/task/task_set_ancestor.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SetAncestorTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+set-ancestor",
|
||||
Description: "set or clear a task ancestor",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task guid to update", Required: true},
|
||||
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
taskID := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
|
||||
QueryParams: queryParams,
|
||||
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
|
||||
}
|
||||
}
|
||||
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{
|
||||
"guid": taskID,
|
||||
},
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
|
||||
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
|
||||
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
|
||||
if ancestorID == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ancestor_guid": ancestorID,
|
||||
}
|
||||
}
|
||||
166
shortcuts/task/task_set_ancestor_test.go
Normal file
166
shortcuts/task/task_set_ancestor_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildSetAncestorBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestorID string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
|
||||
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildSetAncestorBody(tt.ancestorID)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
|
||||
}
|
||||
for k, want := range tt.want {
|
||||
if got[k] != want {
|
||||
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskID string
|
||||
ancestor string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with ancestor",
|
||||
taskID: "task-123",
|
||||
ancestor: "task-456",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
|
||||
},
|
||||
{
|
||||
name: "clear ancestor",
|
||||
taskID: "task-123",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("ancestor-id", "", "")
|
||||
_ = cmd.Flags().Set("task-id", tt.taskID)
|
||||
if tt.ancestor != "" {
|
||||
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
|
||||
out := SetAncestorTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json output with ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`},
|
||||
},
|
||||
{
|
||||
name: "pretty output clears ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
|
||||
},
|
||||
{
|
||||
name: "api-level error (code!=0) returns error",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10003,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if out := stdout.String(); out != "" {
|
||||
t.Fatalf("expected empty stdout on error, got: %s", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
shortcuts/task/task_subscribe_event.go
Normal file
58
shortcuts/task/task_subscribe_event.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SubscribeTaskEvent = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+subscribe-event",
|
||||
Description: "subscribe to task events",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/task_v2/task_subscription").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
|
||||
// Parse and validate the envelope to avoid false-success output.
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
|
||||
}
|
||||
}
|
||||
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{"ok": true}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
131
shortcuts/task/task_subscribe_event_test.go
Normal file
131
shortcuts/task/task_subscribe_event_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSubscribeTaskEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "execute json (user identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute json (bot identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute api error",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 401,
|
||||
"msg": "Unauthorized",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
wantParts: []string{"Unauthorized"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
mode: "dryrun",
|
||||
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
switch tt.mode {
|
||||
case "execute":
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
if tt.register != nil {
|
||||
tt.register(reg)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
out := err.Error()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("error missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
case "dryrun":
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
|
||||
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
209
shortcuts/task/task_tasklist_search.go
Normal file
209
shortcuts/task/task_tasklist_search.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tasklistSearchDefaultPageLimit = 20
|
||||
tasklistSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-search",
|
||||
Description: "search tasklists",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:tasklist:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTasklistSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = tasklistSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > tasklistSearchMaxPageLimit {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasklists")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
tasklistID, _ := itemMap["id"].(string)
|
||||
if tasklistID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tasklist, err := getTasklistDetail(runtime, tasklistID)
|
||||
if err != nil {
|
||||
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklistID,
|
||||
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
urlVal, _ := tasklist["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklist["guid"],
|
||||
"name": tasklist["name"],
|
||||
"url": urlVal,
|
||||
"creator": tasklist["creator"],
|
||||
})
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": tasklists,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
return
|
||||
}
|
||||
for i, tasklist := range tasklists {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
|
||||
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["user_id"] = ids
|
||||
}
|
||||
if createTime := runtime.Str("create-time"); createTime != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(createTime)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
|
||||
}
|
||||
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
|
||||
mergeIntoFilter(filter, timeFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
if tasklist == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
|
||||
}
|
||||
return tasklist, nil
|
||||
}
|
||||
263
shortcuts/task/task_tasklist_search_test.go
Normal file
263
shortcuts/task/task_tasklist_search_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "creator create-time and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("creator", "ou_creator")
|
||||
_ = cmd.Flags().Set("create-time", "-7d,+0d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := createTime["start_time"].(string)
|
||||
endTime, _ := createTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected create_time: %#v", createTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTasklistSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("create-time", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTasklist.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-fallback"`},
|
||||
},
|
||||
{
|
||||
name: "pretty fallback avoids nil name",
|
||||
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasklists found."},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,9 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式不支持跨表引用(IMPORTRANGE)
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号
|
||||
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
|
||||
|
||||
@@ -17,7 +17,7 @@ Subscribe to Lark events via WebSocket long connection, outputting NDJSON to std
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Subscribe to all registered events (catch-all mode, 24 common event types)
|
||||
# Subscribe to all registered events (catch-all mode, 25 common event types)
|
||||
lark-cli event +subscribe
|
||||
|
||||
# Subscribe to specific event types only
|
||||
@@ -153,6 +153,7 @@ The following 24 event types are registered in catch-all mode (when `--event-typ
|
||||
| Event Type | Description | Required Scope |
|
||||
|-----------|-------------|---------------|
|
||||
| `task.task.update_tenant_v1` | Task updated (tenant) | `task:task:readonly` |
|
||||
| `task.task.update_user_access_v2` | Task updated (user access) | `task:task:readonly` |
|
||||
| `task.task.comment_updated_v1` | Task comment updated | `task:task:readonly` |
|
||||
|
||||
### Drive
|
||||
|
||||
@@ -170,10 +170,12 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式不支持跨表引用(IMPORTRANGE)
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号
|
||||
- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-set-dropdown.md`](references/lark-sheets-set-dropdown.md)。值中的字符串不能包含逗号
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -210,6 +212,15 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column |
|
||||
| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition |
|
||||
|
||||
### 下拉列表
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+set-dropdown`](references/lark-sheets-set-dropdown.md) | 设置下拉列表(`multipleValue` 写入的前置步骤) |
|
||||
| [`+update-dropdown`](references/lark-sheets-update-dropdown.md) | 更新下拉列表选项 |
|
||||
| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 |
|
||||
| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 |
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
|
||||
46
skills/lark-sheets/references/lark-sheets-delete-dropdown.md
Normal file
46
skills/lark-sheets/references/lark-sheets-delete-dropdown.md
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
# sheets +delete-dropdown(删除下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-dropdown`。
|
||||
|
||||
删除指定范围的下拉列表配置。支持一次删除多个范围。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除单个范围
|
||||
lark-cli sheets +delete-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100"]'
|
||||
|
||||
# 删除多个范围
|
||||
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A2:A100"]'`),单个范围最多 5000 格,单次最多 100 个范围 |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `rangeResults[].range` — 对应的范围
|
||||
- `rangeResults[].success` — 是否成功
|
||||
- `rangeResults[].updatedCells` — 影响的单元格数量
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
89
skills/lark-sheets/references/lark-sheets-formula.md
Normal file
89
skills/lark-sheets/references/lark-sheets-formula.md
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
# 飞书表格公式规则
|
||||
|
||||
> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认"投影"(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。
|
||||
|
||||
## 写入方式
|
||||
|
||||
公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」):
|
||||
|
||||
```bash
|
||||
--values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]'
|
||||
```
|
||||
|
||||
## ARRAYFORMULA 判断流程
|
||||
|
||||
1. 结果是**标量**(单值)→ 不需要
|
||||
2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播)
|
||||
3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA`
|
||||
|
||||
```text
|
||||
# 有原生数组函数,无需包裹
|
||||
=FILTER(A2:A10,B2:B10="x")+1 ✓
|
||||
=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓
|
||||
=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓
|
||||
|
||||
# 无原生数组函数,必须包裹
|
||||
=ARRAYFORMULA(A2:A100*B2:B100) ✓
|
||||
=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓
|
||||
```
|
||||
|
||||
## 原生数组函数清单(无需 ARRAYFORMULA)
|
||||
|
||||
`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
|
||||
|
||||
## 高风险函数:INDEX / OFFSET / ROW / COLUMN / MATCH
|
||||
|
||||
行号/列号/偏移量本身是数组时,必须显式包裹:
|
||||
|
||||
```text
|
||||
=ARRAYFORMULA(INDEX(...))
|
||||
=ARRAYFORMULA(ROW(...))
|
||||
```
|
||||
|
||||
例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))` ✓
|
||||
|
||||
## 隐式逐项求值 → MAP/LAMBDA
|
||||
|
||||
Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 MAP 显式遍历:
|
||||
|
||||
```text
|
||||
# Excel
|
||||
=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))
|
||||
|
||||
# 飞书
|
||||
=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r)))))
|
||||
```
|
||||
|
||||
同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 MAP。
|
||||
|
||||
## 多维结果降维
|
||||
|
||||
飞书公式结果只能是二维,不能返回"区域的列表"。合并多个区域时:
|
||||
|
||||
| 需求 | 写法 |
|
||||
|------|------|
|
||||
| 上下堆叠 | `=VSTACK(a, b, c)` |
|
||||
| 左右拼接 | `=HSTACK(a, b, c)` |
|
||||
| 压成单列 | `=TOCOL(...)` |
|
||||
| 压成单行 | `=TOROW(...)` |
|
||||
| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` |
|
||||
|
||||
## 日期差
|
||||
|
||||
| 需求 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 天数差 | `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` | `=DAY(B2-A2)` |
|
||||
| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` |
|
||||
| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` |
|
||||
| 工作日差 | `=NETWORKDAYS(A2,B2)` | — |
|
||||
|
||||
## 飞书不支持的 Excel 语法
|
||||
|
||||
| Excel 语法 | 飞书替代 |
|
||||
|-----------|---------|
|
||||
| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@`) |
|
||||
| `=A1#`(spill range) | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` |
|
||||
| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) |
|
||||
| `{=A1:A10*B1:B10}`(CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` |
|
||||
| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 |
|
||||
43
skills/lark-sheets/references/lark-sheets-get-dropdown.md
Normal file
43
skills/lark-sheets/references/lark-sheets-get-dropdown.md
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
# sheets +get-dropdown(查询下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-dropdown`。
|
||||
|
||||
查询指定范围内已配置的下拉列表设置,包括选项值、是否多选、颜色映射等。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100"
|
||||
|
||||
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `dataValidations[].conditionValues` — 下拉选项列表
|
||||
- `dataValidations[].ranges` — 应用范围
|
||||
- `dataValidations[].options.multipleValues` — 是否多选
|
||||
- `dataValidations[].options.highlightValidData` — 是否着色
|
||||
- `dataValidations[].options.colorValueMap` — 选项颜色映射
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
62
skills/lark-sheets/references/lark-sheets-set-dropdown.md
Normal file
62
skills/lark-sheets/references/lark-sheets-set-dropdown.md
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
# sheets +set-dropdown(设置下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +set-dropdown`。
|
||||
|
||||
为指定范围的单元格配置下拉列表选项。**这是使用 `multipleValue` 格式写入数据的前置步骤**——未配置下拉选项的单元格,`multipleValue` 写入会变成纯文本。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础:设置单选下拉
|
||||
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
|
||||
|
||||
# 多选 + 颜色高亮
|
||||
lark-cli sheets +set-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' \
|
||||
--multiple --highlight --colors '["#1FB6C1", "#F006C2", "#FB16C3"]'
|
||||
|
||||
# 仅预览参数(不发请求)
|
||||
lark-cli sheets +set-dropdown --url "https://..." --range "..." --condition-values '...' --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`),单次最多 5000 行 x 100 列 |
|
||||
| `--condition-values` | 是 | 下拉选项,JSON 数组(如 `'["选项1","选项2"]'`),最多 500 个,每个 ≤100 字符,不能包含逗号 |
|
||||
| `--multiple` | 否 | 是否多选,默认 false |
|
||||
| `--highlight` | 否 | 是否着色,默认 false |
|
||||
| `--colors` | 否 | RGB 十六进制颜色 JSON 数组,需与 `--condition-values` 一一对应(`--highlight` 时必填) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `code`(0=成功)和 `msg`。
|
||||
|
||||
## 典型流程
|
||||
|
||||
```bash
|
||||
# 1. 先配置下拉选项
|
||||
lark-cli sheets +set-dropdown --url "<url>" \
|
||||
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
|
||||
|
||||
# 2. 再用 multipleValue 写入
|
||||
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
|
||||
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
51
skills/lark-sheets/references/lark-sheets-update-dropdown.md
Normal file
51
skills/lark-sheets/references/lark-sheets-update-dropdown.md
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
# sheets +update-dropdown(更新下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-dropdown`。
|
||||
|
||||
更新已有下拉列表的选项、颜色等配置。可同时更新多个范围。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!A1:A100", "<sheetId>!C1:C100"]' \
|
||||
--condition-values '["新选项1", "新选项2", "新选项3"]'
|
||||
|
||||
# 更新为多选 + 着色
|
||||
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!A1:A100"]' \
|
||||
--condition-values '["选项A", "选项B"]' \
|
||||
--multiple --highlight --colors '["#1FB6C1", "#F006C2"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A1:A100"]'`) |
|
||||
| `--condition-values` | 是 | 新的下拉选项,JSON 数组 |
|
||||
| `--multiple` | 否 | 是否多选,默认 false |
|
||||
| `--highlight` | 否 | 是否着色,默认 false |
|
||||
| `--colors` | 否 | RGB 颜色 JSON 数组,需与 `--condition-values` 一一对应 |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `spreadsheetToken`、`sheetId`、`dataValidation`(选项值和颜色映射)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
@@ -12,7 +12,9 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
> **搜索技巧**:如果用户的查询只指定了任务名称(例如“完成任务龙虾一号”),请直接使用 `+get-my-tasks --query "龙虾一号"` 命令搜索(不要带 `--complete` 参数,这样可以同时搜索未完成和已完成的任务)。
|
||||
> **任务搜索技巧**:先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**查询关键字**(例如任务名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了任务查询关键字,则目标是**任务**时优先使用 `+search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“今年以来”“已完成”“由我创建”“我关注的”),并且使用 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都能达到目的时,应优先使用列表型能力,而不是搜索型能力。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。
|
||||
> **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。
|
||||
> **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。
|
||||
> **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。
|
||||
> **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。
|
||||
> **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。
|
||||
@@ -24,7 +26,8 @@ metadata:
|
||||
|
||||
> **查询注意**:
|
||||
> 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。
|
||||
> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。
|
||||
> 2. 在输出清单详情时,如果需要渲染 owner、member、角色成员等人员字段,也必须像任务成员展示一样,除了展示 `id` 外,尽量解析并展示对应人员的真实名字。
|
||||
> 3. 在输出任务或清单详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。
|
||||
|
||||
> **Task GUID 定义**:
|
||||
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。
|
||||
@@ -41,7 +44,12 @@ metadata:
|
||||
- [`+followers`](./references/lark-task-followers.md) — Manage task followers
|
||||
- [`+reminder`](./references/lark-task-reminder.md) — Manage task reminders
|
||||
- [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me
|
||||
- [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) — List tasks related to me
|
||||
- [`+search`](./references/lark-task-search.md) — Search tasks
|
||||
- [`+subscribe-event`](./references/lark-task-subscribe-event.md) — Subscribe to task events
|
||||
- [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor
|
||||
- [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks
|
||||
- [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists
|
||||
- [`+tasklist-task-add`](./references/lark-task-tasklist-task-add.md) — Add existing tasks to a tasklist
|
||||
- [`+tasklist-members`](./references/lark-task-tasklist-members.md) — Manage tasklist members
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ lark-cli task +create --summary "Test Task" --dry-run
|
||||
## Workflow
|
||||
|
||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status --json` or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
|
||||
2. Execute `lark-cli task +create --summary "..." ...`
|
||||
3. Report the result: task ID and summary.
|
||||
|
||||
|
||||
53
skills/lark-task/references/lark-task-get-related-tasks.md
Normal file
53
skills/lark-task/references/lark-task-get-related-tasks.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# task +get-related-tasks
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
|
||||
>
|
||||
> **Pagination / Time Cursor Rule:**
|
||||
> In `+get-related-tasks`, `page_token` is the task `updated_at` cursor in microseconds.
|
||||
>
|
||||
> **Execution Priority:**
|
||||
> 1. If the request contains a start/end time boundary (for example, "今年以来", "最近一个月", "从 3 月 1 日开始"), first convert the **start time** boundary to a microsecond `page_token` and query from that token.
|
||||
> 2. Continue pagination using returned `page_token` until `has_more=false`, but never exceed 40 total page fetches.
|
||||
> 3. Do NOT default to `--page-all` for time-bounded queries.
|
||||
>
|
||||
> Only use `--page-all` from the beginning when:
|
||||
> 1. the user explicitly asks for a full scan of all related tasks, or
|
||||
> 2. no time boundary can be inferred from the request.
|
||||
|
||||
List tasks related to the current user.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# List all related tasks
|
||||
lark-cli task +get-related-tasks
|
||||
|
||||
# List incomplete related tasks starting from a page token
|
||||
lark-cli task +get-related-tasks --include-complete=false --page-token "1752730590582902"
|
||||
|
||||
# Show only tasks created by me
|
||||
lark-cli task +get-related-tasks --created-by-me
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--include-complete=<bool>` | No | Default behavior includes completed tasks. Set to `false` to keep only incomplete tasks. |
|
||||
| `--page-all` | No | Automatically paginate through all pages (max 40). |
|
||||
| `--page-limit <int>` | No | Max page limit (default 20). |
|
||||
| `--page-token <string>` | No | Start from the specified page token. This token is the task's last update time cursor in microseconds. |
|
||||
| `--created-by-me` | No | Keep only tasks whose creator is the current user. This is a client-side filter applied after fetching related-task pages. |
|
||||
| `--followed-by-me` | No | Keep only tasks followed by the current user. This is a client-side filter applied after fetching related-task pages. |
|
||||
|
||||
> **Page Token Note:** In `+get-related-tasks`, the `page_token` is a microsecond-level cursor representing the task's last update time. For example, `1752730590582902` should be treated as an updated-at cursor, not a task ID.
|
||||
>
|
||||
> **Pagination Note for Client-side Filters:** When `--created-by-me` or `--followed-by-me` is used, filtering happens locally after each upstream related-task page is fetched. The returned `has_more` and `page_token` still describe the upstream cursor, so later pages may contain more matching tasks, or may contain none.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine whether the user needs all related tasks or a filtered subset.
|
||||
2. Execute `lark-cli task +get-related-tasks ...`
|
||||
3. Report the matching tasks and, if present, the next `page_token`.
|
||||
41
skills/lark-task/references/lark-task-search.md
Normal file
41
skills/lark-task/references/lark-task-search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# task +search
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
|
||||
|
||||
Search tasks by keyword and optional filters.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# Search by keyword
|
||||
lark-cli task +search --query "test"
|
||||
|
||||
# Search incomplete tasks assigned to specific users
|
||||
lark-cli task +search --assignee "ou_xxx,ou_yyy" --completed=false
|
||||
|
||||
# Search by due time range
|
||||
lark-cli task +search --query "release" --due "-1d,+7d"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--query <string>` | No | Search keyword. If omitted, at least one filter must be provided. |
|
||||
| `--creator <ids>` | No | Creator open_ids, comma-separated. |
|
||||
| `--assignee <ids>` | No | Assignee open_ids, comma-separated. |
|
||||
| `--follower <ids>` | No | Follower open_ids, comma-separated. |
|
||||
| `--completed=<bool>` | No | Filter by completion state. |
|
||||
| `--due <range>` | No | Due time range in `start,end` form. Each side supports ISO/date/relative/ms input. |
|
||||
| `--page-token <string>` | No | Page token for pagination. |
|
||||
| `--page-all` | No | Automatically paginate through all pages (max 40). |
|
||||
| `--page-limit <int>` | No | Max page limit (default 20). |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build the keyword and filters from the user's request.
|
||||
2. Execute `lark-cli task +search ...`
|
||||
3. Report the matched tasks and include the next `page_token` if more results exist.
|
||||
|
||||
32
skills/lark-task/references/lark-task-set-ancestor.md
Normal file
32
skills/lark-task/references/lark-task-set-ancestor.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# task +set-ancestor
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
|
||||
Set a parent task for a task, or clear the parent to make it independent.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# Set a parent task
|
||||
lark-cli task +set-ancestor --task-id "guid_1" --ancestor-id "guid_2"
|
||||
|
||||
# Clear the parent task
|
||||
lark-cli task +set-ancestor --task-id "guid_1"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--task-id <guid>` | Yes | The task GUID to update. |
|
||||
| `--ancestor-id <guid>` | No | The parent task GUID. Omit it to clear the ancestor. |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the child task and, if applicable, the ancestor task.
|
||||
2. Execute `lark-cli task +set-ancestor ...`
|
||||
3. Report the updated task GUID and whether the ancestor was set or cleared.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
86
skills/lark-task/references/lark-task-subscribe-event.md
Normal file
86
skills/lark-task/references/lark-task-subscribe-event.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# task +subscribe-event
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe tasks the **application is responsible for**.
|
||||
|
||||
Subscribe task update events with the current identity.
|
||||
|
||||
This shortcut is different from `event +subscribe`:
|
||||
- `task +subscribe-event` registers task-event access for the **current identity**
|
||||
- with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow
|
||||
- with `--as bot`, it subscribes using the **application identity** for tasks the application is responsible for
|
||||
|
||||
The task event type is:
|
||||
|
||||
```text
|
||||
task.task.update_user_access_v2
|
||||
```
|
||||
|
||||
Within this event, task changes are represented by commit types (string values). Deduped list:
|
||||
|
||||
```text
|
||||
task_assignees_update
|
||||
task_completed_update
|
||||
task_create
|
||||
task_deleted
|
||||
task_desc_update
|
||||
task_followers_update
|
||||
task_reminders_update
|
||||
task_start_due_update
|
||||
task_summary_update
|
||||
```
|
||||
|
||||
Event payload shape (example):
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_xxx",
|
||||
"event_types": ["task_summary_update"],
|
||||
"task_guid": "task_guid_xxx",
|
||||
"timestamp": "1775793266152",
|
||||
"type": "task.task.update_user_access_v2"
|
||||
}
|
||||
```
|
||||
|
||||
- `type`: event type, should be `task.task.update_user_access_v2`
|
||||
- `event_id`: unique event id (useful for dedup)
|
||||
- `event_types`: list of commit types (see the deduped list above)
|
||||
- `task_guid`: the task GUID that changed
|
||||
- `timestamp`: event timestamp (ms)
|
||||
|
||||
In practice, this means:
|
||||
- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following
|
||||
- with `--as bot`, the subscription covers tasks the application is responsible for
|
||||
|
||||
To actually receive the subscribed events, use the standard event WebSocket receiver:
|
||||
|
||||
```bash
|
||||
lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compact --quiet
|
||||
```
|
||||
|
||||
The full flow is:
|
||||
1. Register the subscription with `lark-cli task +subscribe-event [--as user|bot]`
|
||||
2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...`
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
lark-cli task +subscribe-event
|
||||
```
|
||||
# Subscribe with app identity
|
||||
lark-cli task +subscribe-event --as bot
|
||||
|
||||
|
||||
## Parameters
|
||||
|
||||
This shortcut has no additional parameters.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm whether the user wants to subscribe with `user` identity or `bot` identity.
|
||||
2. Execute `lark-cli task +subscribe-event`
|
||||
3. Report whether the subscription succeeded, and clarify which identity the subscription applies to.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
38
skills/lark-task/references/lark-task-tasklist-search.md
Normal file
38
skills/lark-task/references/lark-task-tasklist-search.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# task +tasklist-search
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This shortcut uses tasklist search followed by tasklist detail queries to render the final output.
|
||||
|
||||
Search tasklists by keyword and optional filters.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# Search by keyword
|
||||
lark-cli task +tasklist-search --query "测试"
|
||||
|
||||
# Search tasklists created by specific users
|
||||
lark-cli task +tasklist-search --creator "ou_xxx,ou_yyy"
|
||||
|
||||
# Search by creation time range
|
||||
lark-cli task +tasklist-search --query "Q2" --create-time "-30d,+0d"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--query <string>` | No | Search keyword. If omitted, at least one filter must be provided. |
|
||||
| `--creator <ids>` | No | Creator open_ids, comma-separated. |
|
||||
| `--create-time <range>` | No | Creation time range in `start,end` form. Each side supports ISO/date/relative/ms input. |
|
||||
| `--page-token <string>` | No | Page token for pagination. |
|
||||
| `--page-all` | No | Automatically paginate through all pages (max 40). |
|
||||
| `--page-limit <int>` | No | Max page limit (default 20). |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build the search keyword and filters from the user's request.
|
||||
2. Execute `lark-cli task +tasklist-search ...`
|
||||
3. Report the matched tasklists and the next `page_token` if more results exist.
|
||||
|
||||
Reference in New Issue
Block a user