mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts * docs: clarify base coordinate resolution * fix(base): address resolve shortcut ci * fix(base): format resolved record share hint * fix(base): simplify record share hint data * fix(base): use field ids in resolved record data * fix(base): guide record share resolve to update record * fix(base): include record upsert example in resolve hint * fix(base): reject add-record urls in resolver * fix(base): validate title resolve query length * fix(base): hide resolve alias flags from help * fix(base): prefer title flag for title resolve * docs(base): clarify token resolution wording
This commit is contained in:
545
shortcuts/base/base_resolve.go
Normal file
545
shortcuts/base/base_resolve.go
Normal file
@@ -0,0 +1,545 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
|
||||
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepRecordList = "use +record-list to list records in the resolved table"
|
||||
titleResolveQueryMaxLen = 30
|
||||
)
|
||||
|
||||
var BaseURLResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+url-resolve",
|
||||
Description: "Resolve a Base-related URL into Base coordinates",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readURLResolveInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
|
||||
default:
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseURLResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTitleResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+title-resolve",
|
||||
Description: "Resolve a Base title or keyword through Drive search",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:docs:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readTitleResolveQuery(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/search/v2/doc_wiki/search").
|
||||
Body(buildTitleResolveSearchBody(query))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseTitleResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
|
||||
return "", resolveValidationError(
|
||||
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
|
||||
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
}
|
||||
return pickedValue, nil
|
||||
}
|
||||
|
||||
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
out, err := resolveWikiBaseURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
out, err := resolveRecordShareURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "form_share_url":
|
||||
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
|
||||
return nil
|
||||
case "view_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "dashboard_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "workspace_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "add_record_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
default:
|
||||
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolveURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func classifyBaseURL(u *url.URL) string {
|
||||
path := normalizeResolvePath(u.Path)
|
||||
switch {
|
||||
case pathSegmentExists(path, "/base/workspace/"):
|
||||
return "workspace_url"
|
||||
case pathSegmentExists(path, "/base/add/"):
|
||||
return "add_record_url"
|
||||
case pathSegmentExists(path, "/base/"):
|
||||
return "base_url"
|
||||
case pathSegmentExists(path, "/wiki/"):
|
||||
return "wiki_url"
|
||||
case pathSegmentExists(path, "/record/"):
|
||||
return "record_share_url"
|
||||
case pathSegmentExists(path, "/share/base/form/"):
|
||||
return "form_share_url"
|
||||
case pathSegmentExists(path, "/share/base/view/"):
|
||||
return "view_share_url"
|
||||
case pathSegmentExists(path, "/share/base/dashboard/"):
|
||||
return "dashboard_share_url"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
|
||||
if objType != "bitable" {
|
||||
return nil, resolveValidationError(
|
||||
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
|
||||
"Use the corresponding skill for that resource, or provide a Base URL.",
|
||||
)
|
||||
}
|
||||
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
|
||||
if baseToken == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"input_type": "wiki_url",
|
||||
"resource_type": "bitable",
|
||||
"wiki_node_token": token,
|
||||
"base_token": baseToken,
|
||||
"title": common.GetString(node, "title"),
|
||||
"hint": resolveHint("", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
shareToken := firstPathSegmentAfter(u.Path, "/record/")
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"input_type": "record_share_url",
|
||||
"resource_type": "bitable",
|
||||
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
|
||||
"base_token": common.GetString(data, "base_token"),
|
||||
"table_id": common.GetString(data, "table_id"),
|
||||
"record_id": common.GetString(data, "record_id"),
|
||||
}
|
||||
enrichRecordShareResolveHint(runtime, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveFormShareURL(u *url.URL) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"input_type": "form_share_url",
|
||||
"resource_type": "bitable_form",
|
||||
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return resolveValidationError(
|
||||
"No Base matched this title or keyword.",
|
||||
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
case 1:
|
||||
out := map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"title": candidates[0]["title"],
|
||||
"base_token": candidates[0]["base_token"],
|
||||
"url": candidates[0]["url"],
|
||||
"owner_name": candidates[0]["owner_name"],
|
||||
"update_time": candidates[0]["update_time"],
|
||||
"hint": resolveHint("", nil),
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"candidates": candidates,
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": baseTitleResolveHint,
|
||||
},
|
||||
}, nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
return
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
|
||||
hint := map[string]interface{}{}
|
||||
if baseToken != "" && tableID != "" && recordID != "" {
|
||||
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
|
||||
hint["record_data"] = formatResolvedRecordData(record)
|
||||
}
|
||||
}
|
||||
if baseToken != "" && tableID != "" {
|
||||
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
|
||||
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
|
||||
}
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, hint)
|
||||
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{"record_id_list": []string{recordID}}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
|
||||
return handleBaseAPIResult(result, err, "batch get records")
|
||||
}
|
||||
|
||||
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
|
||||
fieldIDs := common.GetSlice(record, "field_id_list")
|
||||
fieldNames := common.GetSlice(record, "fields")
|
||||
rows := common.GetSlice(record, "data")
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if len(rows) > 0 {
|
||||
if values, ok := rows[0].([]interface{}); ok {
|
||||
for i, value := range values {
|
||||
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
|
||||
if index < len(fieldIDs) {
|
||||
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
|
||||
return fieldID
|
||||
}
|
||||
}
|
||||
if index < len(fieldNames) {
|
||||
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("field_%d", index+1)
|
||||
}
|
||||
|
||||
func recordShareNextStep(baseToken, tableID, recordID string) string {
|
||||
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
|
||||
hint := map[string]interface{}{}
|
||||
for key, value := range extra {
|
||||
hint[key] = value
|
||||
}
|
||||
if strings.TrimSpace(tableID) != "" {
|
||||
hint["next_step"] = nextStepRecordList
|
||||
} else {
|
||||
hint["next_step"] = nextStepBaseBlockList
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
func buildTitleResolveSearchBody(query string) map[string]interface{} {
|
||||
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
|
||||
return map[string]interface{}{
|
||||
"query": query,
|
||||
"page_size": 5,
|
||||
"doc_filter": filter,
|
||||
"wiki_filter": filter,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
|
||||
candidates := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
meta, _ := row["result_meta"].(map[string]interface{})
|
||||
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(meta, "token"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(common.GetString(row, "title"))
|
||||
}
|
||||
candidates = append(candidates, map[string]interface{}{
|
||||
"title": title,
|
||||
"base_token": token,
|
||||
"url": common.GetString(meta, "url"),
|
||||
"owner_name": common.GetString(meta, "owner_name"),
|
||||
"update_time": common.GetString(meta, "update_time_iso"),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
|
||||
|
||||
func stripSearchHighlight(s string) string {
|
||||
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func resolveValidationError(message, hint string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func normalizeResolvePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathSegmentExists(path, prefix string) bool {
|
||||
return firstPathSegmentAfter(path, prefix) != ""
|
||||
}
|
||||
|
||||
func firstPathSegmentAfter(path, prefix string) string {
|
||||
path = normalizeResolvePath(path)
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func valueOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unknown resource type"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
454
shortcuts/base/base_resolve_test.go
Normal file
454
shortcuts/base/base_resolve_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base only", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepBaseBlockList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not Base") {
|
||||
t.Fatalf("err=%v, want non-Base validation error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
recordData, _ := hint["record_data"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enrichment failure still returns meta", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["record_data"]; ok {
|
||||
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_share_token": shareToken,
|
||||
"base_token": baseToken,
|
||||
"table_id": tableID,
|
||||
"record_id": recordID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveFormShareURL(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantText string
|
||||
wantHint string
|
||||
}{
|
||||
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
|
||||
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
|
||||
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
|
||||
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
|
||||
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
|
||||
{"not url", "bas123", "only accepts full URLs", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", tc.rawURL, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("err=%v, want typed error with hint", err)
|
||||
}
|
||||
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
|
||||
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "original /base/{base_token}") {
|
||||
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
shortcut string
|
||||
definition common.Shortcut
|
||||
primaryFlag string
|
||||
primaryDesc string
|
||||
aliasFlags []string
|
||||
}{
|
||||
{
|
||||
shortcut: "+url-resolve",
|
||||
definition: BaseURLResolve,
|
||||
primaryFlag: "url",
|
||||
primaryDesc: "Base/Wiki/record-share URL to resolve",
|
||||
aliasFlags: []string{"query"},
|
||||
},
|
||||
{
|
||||
shortcut: "+title-resolve",
|
||||
definition: BaseTitleResolve,
|
||||
primaryFlag: "title",
|
||||
primaryDesc: "Base title keyword",
|
||||
aliasFlags: []string{"query", "url"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tc.definition.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
primary := cmd.Flags().Lookup(tc.primaryFlag)
|
||||
primaryUsage := ""
|
||||
if primary != nil {
|
||||
primaryUsage = primary.Usage
|
||||
}
|
||||
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
|
||||
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTitleResolve(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Sales <h>Pipeline</h>",
|
||||
"result_meta": map[string]interface{}{
|
||||
"doc_types": "BITABLE",
|
||||
"token": "bas123",
|
||||
"url": "https://example.larkoffice.com/base/bas123",
|
||||
"owner_name": "Alice",
|
||||
"update_time_iso": "2026-06-09T10:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple results and filter non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Doc hit",
|
||||
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>One</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>Two</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--url", "Base", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates=%#v, want 2", data["candidates"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub(nil))
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "missing", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
|
||||
t.Fatalf("err=%v, want no result validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query too long", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
|
||||
t.Fatalf("err=%v, want query length validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"res_units": items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"total": 2,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
|
||||
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{recordID},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_status"},
|
||||
"fields": []interface{}{"Name", "Status"},
|
||||
"data": []interface{}{[]interface{}{"Alice", "Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+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",
|
||||
|
||||
@@ -8,6 +8,8 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseURLResolve,
|
||||
BaseTitleResolve,
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
|
||||
@@ -31,10 +31,17 @@ metadata:
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 用户只给 Base 名称或关键词时,先用 `lark-cli drive +search --query <keyword> --doc-types bitable` 定位资源。
|
||||
- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "<base title>" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
## 先获取 Base Token 和所需 ID
|
||||
|
||||
进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID;不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`。
|
||||
|
||||
- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "<url>" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。
|
||||
- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "<keyword>" --as user`;`--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。
|
||||
- 文档嵌入 Base 标签:直接读取 `<bitable>` / `<base_refer>` 的 `token` 作为 `--base-token`,`table-id` 作为 `--table-id`,`view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`。
|
||||
- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base;用户要新建时才用 `+base-create`。
|
||||
|
||||
## 快速路由
|
||||
|
||||
| 用户目标 | 优先命令 | 何时读 reference |
|
||||
@@ -113,22 +120,6 @@ metadata:
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
## Token 与链接
|
||||
|
||||
| 输入类型 | 含义 / 正确处理方式 |
|
||||
|---|---|
|
||||
| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` |
|
||||
| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` |
|
||||
| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id`;`blk` 开头是 dashboard ID;`wkf` 开头是 workflow ID |
|
||||
| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 |
|
||||
| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token,走 `+form-detail` / `+form-submit --share-token <shareToken>` |
|
||||
| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
|
||||
| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
|
||||
| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>` |
|
||||
| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 |
|
||||
|
||||
`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。
|
||||
|
||||
## Dashboard / Workflow / Role
|
||||
|
||||
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。
|
||||
@@ -139,7 +130,7 @@ metadata:
|
||||
|
||||
| 错误 / 现象 | 恢复动作 |
|
||||
|---|---|
|
||||
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
|
||||
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按入口规则重新获取真实 `base_token` |
|
||||
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
||||
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
|
||||
|
||||
Reference in New Issue
Block a user