Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions internal/suggest/suggest.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
// carrying their own copy.
package suggest

import "sort"
import (
"sort"
"strings"
)

// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
Expand Down Expand Up @@ -51,22 +54,29 @@ func Levenshtein(a, b string) int {
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
name string
contain bool
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
ct := containsSegment(typed, c)
// Keep only plausible matches: a meaningful shared prefix, an edit
// distance within budget, or one name containing the other (a missing
// namespace prefix like "+block-list" vs "+base-block-list"). Drop
// everything else so the hint stays short.
if p >= 3 || d <= limit || ct {
ranked = append(ranked, scored{name: c, contain: ct, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].contain != ranked[j].contain {
return ranked[i].contain
}
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
Expand Down Expand Up @@ -94,6 +104,21 @@ func editLimit(s string) int {
return 2
}

// containsSegment reports whether one name contains the other as a substring
// after stripping the "+"/"--" sigils. It catches hallucinated names that drop
// a namespace prefix (e.g. "+block-list" for "+base-block-list"), which share
// almost no prefix and sit far beyond the edit-distance budget. The shorter
// side must be at least 5 runes so generic fragments like "list" do not match
// half the catalog.
func containsSegment(a, b string) bool {
a = strings.TrimLeft(a, "+-")
b = strings.TrimLeft(b, "+-")
if len([]rune(a)) > len([]rune(b)) {
a, b = b, a
}
return len([]rune(a)) >= 5 && strings.Contains(b, a)
}

func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
Expand Down
5 changes: 5 additions & 0 deletions shortcuts/base/base_data_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"

"github.com/larksuite/cli/shortcuts/common"
)
Expand All @@ -20,6 +21,7 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Hidden: true},
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
},
Tips: []string{
Expand All @@ -28,6 +30,9 @@ var BaseDataQuery = common.Shortcut{
"`dimensions` and `measures` cannot both be empty.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("table-id")) != "" {
return baseFlagErrorf("+data-query does not support --table-id; put table names/fields inside --dsl (read lark-base-data-query-guide.md)")
}
var dsl map[string]interface{}
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()
Expand Down
8 changes: 8 additions & 0 deletions shortcuts/base/base_dryrun_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ func TestDryRunFieldOps(t *testing.T) {
)
assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200")

batchListRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x"},
map[string][]string{"table-id": {"tbl_1", "tbl_2"}},
nil,
map[string]int{"offset": 0, "limit": 50},
)
assertDryRunContains(t, dryRunFieldListBatch(ctx, batchListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50")

rt := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
Expand Down
188 changes: 181 additions & 7 deletions shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,129 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})

t.Run("list resolves table name", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_name", "name": "Name", "type": "text"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Name"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list batch multiple tables", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text", "style": map[string]interface{}{"type": "plain"}},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b", "--compact"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || !strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list batch resolves table names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_order", "name": "Status", "type": "select"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_orders"`) || !strings.Contains(got, `"table_ref": "Orders"`) || !strings.Contains(got, `"table_name": "Orders"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list batch default keeps full fields", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_b"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"color": "red"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Expand Down Expand Up @@ -1223,6 +1346,48 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})

t.Run("search accepts query alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title"},
"field_id_list": []interface{}{"fld_title"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--query", "Created",
"--search-field", "Title",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
}
if body["keyword"] != "Created" {
t.Fatalf("captured body=%#v", body)
}
})

t.Run("search with filter json file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmp := t.TempDir()
Expand Down Expand Up @@ -1314,20 +1479,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})

t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias projects columns", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&limit=100&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
})

t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list fields alias works in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "field_id=Name") {
t.Fatalf("stdout=%s", got)
}
})

t.Run("get", func(t *testing.T) {
Expand Down
35 changes: 34 additions & 1 deletion shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}}
}

func TestFieldSearchOptionsAlias(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil)
if got := fieldSearchOptionsRef(runtime); got != "Status" {
t.Fatalf("field ref=%q", got)
}
if err := BaseFieldSearchOptions.Validate(context.Background(), runtime); err != nil {
t.Fatalf("err=%v", err)
}
}

func TestFieldSearchOptionsRequiresFieldRef(t *testing.T) {
err := BaseFieldSearchOptions.Validate(context.Background(), newBaseTestRuntime(map[string]string{}, nil, nil))
if err == nil || !strings.Contains(err.Error(), "--field-id is required") {
t.Fatalf("err=%v", err)
}
}

func TestBaseAction(t *testing.T) {
t.Run("missing action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil)
Expand Down Expand Up @@ -135,7 +152,7 @@ func TestShortcutsCatalog(t *testing.T) {
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+field-list", "+field-list-batch", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
Expand Down Expand Up @@ -1036,6 +1053,22 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "query": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search query alias validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice", "query": "Bob"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "use only one") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
Expand Down
1 change: 1 addition & 0 deletions shortcuts/base/dashboard_block_get_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "dashboard-id", Hidden: true},
blockIDFlag(true),
},
Tips: []string{
Expand Down
1 change: 1 addition & 0 deletions shortcuts/base/field_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var BaseFieldList = common.Shortcut{
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
{Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"},
},
DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand Down
Loading
Loading