mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
723f884e9b
* feat(base): add BaseApp workspace, page and block shortcuts Implement the CLI layer of the BaseApp CLI/OpenAPI protocol design: 17 new shortcuts covering workspace entities, blank app creation, page CRUD and page block CRUD, plus skill references and dry-run E2E for each. The data_config validator moves to a neutral block_data_config.go with chart logic unchanged; list and richText dispatch are new branches, so dashboard behaviour is untouched. Command spaces stay separate — dashboard commands never take --app-token and app block commands never take --dashboard-id. The one exception is +app-block-get-data, which shares the dashboard endpoint, execute and dry-run hooks and therefore takes --base-token instead of --app-token. This phase ships no +app-block-delete and no page arrange command; both the help text and the skill docs spell out that a block type cannot be changed after creation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(base): implement app mode shortcuts and list components * feat: support deleting BaseApp via drive delete * fix: 修复 workspace scope * fix: correct BaseApp permission scopes * feat: 新增 moveIn workspace 逻辑 * feat(base): support multi-datasource data_config for BaseApp charts BaseApp page charts follow section 8 图表协议 of the App CLI RPC 协议, which differs from dashboard charts by supporting multiple data sources: base_token is a single top-level value shared by every source, while table_name/series/count_all/group_by/filter move into each data_sources[] element (plus top-level data_source_mode and sort). The per-source value semantics are identical to dashboard charts, so each data_sources[] element reuses normalizeDataConfig / validateChartDataConfig; the wrapper only adds the top-level structure. Dashboard charts keep the flat shape; the list protocol is untouched. - block_data_config.go: add normalizeAppChartDataConfig / validateAppChartDataConfig / validateAppBlockDataConfig - app_block_create/update: route chart blocks to the multi-datasource normalize/validate; refresh tips and examples - reference doc: rewrite the chart section for the multi-datasource shape - unit + e2e tests: migrate chart cases to data_sources; add a multi-datasource combo case Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(base): map BaseApp richText block type to the wire type "text" The rich-text widget's API type is "text" (App CLI RPC 协议 §10), but the CLI exposes the friendlier "richText" alias and was sending it verbatim, so the backend rejected +app-block-create --type richText with "type is invalid". Map richText -> text when building the request body; the user-facing --type richText is unchanged. Add TestAppRichTextTypeMapsToText. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(base): remove internal protocol doc link/reference from skills The BaseApp skill references pointed at an internal Lark doc (deep link with a private token) as the source of truth, which must not ship in this repo. Drop the link and the doc name entirely from the reference markdown and from code comments; describe behavior in neutral terms ("服务端协议 / 服务端返回和校验") instead. No functional change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: create workspace for BaseApp when omitted * fix(base): align BaseApp block protocol * fix: 删掉废弃的 workspace-entity-remove * fix(base): align app get reference response * fix(base): align app shortcuts with API contract * fix(base): refine app mode shortcut contracts * fix: use entity_type for workspace entity filtering * fix: return workspace URLs for base workspace ops * fix(base): enforce unique app block names * fix(base): use chart token for app block data * 明确baseapp边界,不导向到dashboard-arrange * docs(base): clarify app copy is unsupported * docs(base): define unsupported app page operations * fix(base): align app block text type with dashboard AppMode 的文本组件此前对外叫 richText,发送时再映射成 wire 上的 text, 而读取方向没有反向映射:写进去用 richText、读回来是 text,同一个 CLI 表面自相矛盾,回填或幂等复建时会被枚举校验拒掉。 统一成 text,与 Dashboard 文本组件同名同义: - appBlockTypes/isAppBlockType/textBlockTypes 去掉 richText - 删除 appBlockBody 里的 richText → text 发送期映射 - help、枚举、示例、tips 与 baseapp block data_config reference 同步 - 新增回归测试,确保 richText 不再被接受也不再出现在枚举里 richText 不保留别名:+app-* 尚未随已发布版本对外,无存量调用方。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(base): resolve BaseApp URLs Migrate the net changes from bitable/base_cli!1308 onto the current BaseApp development branch. * fix(base): remove app block list type filter * docs: preserve explicit intent when reusing BaseApp blocks * fix(base): route +app-block-get-data to base_apps endpoint Move the shortcut off the dashboard route and onto the dedicated BaseApp block-data endpoint: - URL: /open-apis/base/v3/base_apps/:app_token/blocks/:block_id/data - base_token is passed as a required query parameter per the new IDL - Refresh --block-id description and tips to list all producers of the chart_token (create/list/get) and note the cht… prefix - Update the dryrun test to expect the new URL Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: clarify unsupported BaseApp copy paths * fix: front-load BaseApp copy stop rule * fix: surface unsupported PageGroup operations * fix: preserve PageGroup support boundary * docs(base): clarify unsupported app block handling * docs(base): clarify how to read text block content Text blocks have no /data endpoint; calling +app-block-get-data on one returns a generic server 500. Point readers at +app-block-get, whose data_config.text carries the Markdown source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: clarify immutable BaseApp block types * docs(base): correct chart date filter format * docs(base): explain inaccessible app pages * docs: require workspace removal lookup * fix(base): return workspace move-in result faithfully * chore(base): adapt BaseApp changes to upstream main * fix(base): align app mode changes with upstream scope * fix(base): address app mode review feedback * test(base): assert app data transport error contract * fix(base): satisfy app mode merge requirements * refactor(base): align app mode filenames * docs: fix BaseApp rename guidance * fix(base): remove unsupported workspace icon * docs(base): clarify app mode concepts and config reuse --------- Co-authored-by: weibiao.x <weibiao.x@bytedance.com> Co-authored-by: zhangbinkai.zbk <zhangbinkai.zbk@bytedance.com> Co-authored-by: yurunjie <yurunjie.xx@bytedance.com> Co-authored-by: Codex <codex@example.com>
1021 lines
29 KiB
Go
1021 lines
29 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
batchSize = 500
|
|
baseV3ServicePath = "/open-apis/base/v3"
|
|
)
|
|
|
|
type fieldTypeSpec struct {
|
|
Type string
|
|
Extra map[string]interface{}
|
|
}
|
|
|
|
func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]interface{}, error) {
|
|
resolved, err := loadJSONInput(pc, raw, flagName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
|
var syntaxErr *json.SyntaxError
|
|
if errors.As(err, &syntaxErr) {
|
|
return nil, formatJSONError(flagName, "object", err)
|
|
}
|
|
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
|
}
|
|
if result == nil {
|
|
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func parseJSONArray(pc *parseCtx, raw string, flagName string) ([]interface{}, error) {
|
|
resolved, err := loadJSONInput(pc, raw, flagName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []interface{}
|
|
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
|
return nil, formatJSONError(flagName, "array", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func parseStringListFlexible(pc *parseCtx, raw string, flagName string) ([]string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
resolved, err := loadJSONInput(pc, raw, flagName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.HasPrefix(resolved, "[") {
|
|
var result []string
|
|
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
|
return nil, formatJSONError(flagName, "string array", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
raw = resolved
|
|
parts := strings.Split(raw, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
item := strings.TrimSpace(part)
|
|
if item != "" {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func parseStringList(raw string) []string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
item := strings.TrimSpace(part)
|
|
if item != "" {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} {
|
|
if dst == nil {
|
|
dst = map[string]interface{}{}
|
|
}
|
|
for key, value := range src {
|
|
if srcMap, ok := value.(map[string]interface{}); ok {
|
|
if dstMap, ok := dst[key].(map[string]interface{}); ok {
|
|
dst[key] = deepMergeMaps(dstMap, srcMap)
|
|
} else {
|
|
dst[key] = deepMergeMaps(map[string]interface{}{}, srcMap)
|
|
}
|
|
continue
|
|
}
|
|
dst[key] = value
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func cloneMap(src map[string]interface{}) map[string]interface{} {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
dst := make(map[string]interface{}, len(src))
|
|
for key, value := range src {
|
|
dst[key] = cloneValue(value)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func cloneValue(value interface{}) interface{} {
|
|
switch val := value.(type) {
|
|
case map[string]interface{}:
|
|
return cloneMap(val)
|
|
case []interface{}:
|
|
cloned := make([]interface{}, len(val))
|
|
for i, item := range val {
|
|
cloned[i] = cloneValue(item)
|
|
}
|
|
return cloned
|
|
default:
|
|
return val
|
|
}
|
|
}
|
|
|
|
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
|
trimmed := strings.TrimSpace(typeName)
|
|
if trimmed == "" {
|
|
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
|
|
}
|
|
switch strings.ToLower(trimmed) {
|
|
case "text", "phone", "url", "email", "barcode":
|
|
return fieldTypeSpec{Type: "text"}, nil
|
|
case "number":
|
|
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "number", "formatter": "0"}}}, nil
|
|
case "currency":
|
|
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}}}, nil
|
|
case "progress":
|
|
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}}}, nil
|
|
case "rating":
|
|
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}}}, nil
|
|
case "singleselect", "single_select", "single-select":
|
|
return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": false}}, nil
|
|
case "multiselect", "multi_select", "multi-select":
|
|
return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": true}}, nil
|
|
case "datetime", "date", "date_time", "date-time":
|
|
return fieldTypeSpec{Type: "datetime", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
|
case "checkbox":
|
|
return fieldTypeSpec{Type: "checkbox"}, nil
|
|
case "user", "groupchat", "group_chat", "group-chat":
|
|
return fieldTypeSpec{Type: "user", Extra: map[string]interface{}{"multiple": true}}, nil
|
|
case "attachment":
|
|
return fieldTypeSpec{Type: "attachment"}, nil
|
|
case "link":
|
|
return fieldTypeSpec{Type: "link"}, nil
|
|
case "twowaylink", "two_way_link", "two-way-link":
|
|
return fieldTypeSpec{Type: "link", Extra: map[string]interface{}{"bidirectional": true}}, nil
|
|
case "formula":
|
|
return fieldTypeSpec{Type: "formula"}, nil
|
|
case "location":
|
|
return fieldTypeSpec{Type: "location"}, nil
|
|
case "autonumber", "auto_number", "auto-number":
|
|
return fieldTypeSpec{Type: "auto_number", Extra: map[string]interface{}{"style": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"type": "text", "text": "NO."}, map[string]interface{}{"type": "incremental_number", "length": 3}}}}}, nil
|
|
case "createdtime", "created_time", "created-time":
|
|
return fieldTypeSpec{Type: "created_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
|
case "modifiedtime", "modified_time", "modified-time":
|
|
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
|
default:
|
|
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
|
|
}
|
|
}
|
|
|
|
func normalizeFieldTypeName(typeName string) string {
|
|
return strings.TrimSpace(typeName)
|
|
}
|
|
|
|
func normalizeViewTypeName(typeName string) string {
|
|
trimmed := strings.TrimSpace(typeName)
|
|
if trimmed == "" {
|
|
return trimmed
|
|
}
|
|
switch strings.ToLower(trimmed) {
|
|
case "grid":
|
|
return "grid"
|
|
case "kanban":
|
|
return "kanban"
|
|
case "gallery":
|
|
return "gallery"
|
|
case "gantt":
|
|
return "gantt"
|
|
case "calendar":
|
|
return "calendar"
|
|
default:
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
func normalizeSelectOptions(raw interface{}) []interface{} {
|
|
src, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]interface{}, 0, len(src))
|
|
for _, item := range src {
|
|
switch v := item.(type) {
|
|
case string:
|
|
result = append(result, map[string]interface{}{"name": v})
|
|
case map[string]interface{}:
|
|
option := map[string]interface{}{}
|
|
if name, _ := v["name"].(string); name != "" {
|
|
option["name"] = name
|
|
}
|
|
if hue, _ := v["hue"].(string); hue != "" {
|
|
option["hue"] = hue
|
|
}
|
|
if lightness, _ := v["lightness"].(string); lightness != "" {
|
|
option["lightness"] = lightness
|
|
}
|
|
if len(option) > 0 {
|
|
result = append(result, option)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
|
|
if isPrimary {
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
|
|
}
|
|
if isHidden {
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
|
|
}
|
|
spec, err := resolveFieldTypeSpec(typeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body := map[string]interface{}{
|
|
"type": spec.Type,
|
|
"name": fieldName,
|
|
}
|
|
body = deepMergeMaps(body, cloneMap(spec.Extra))
|
|
if description != "" {
|
|
_ = description
|
|
}
|
|
if uiType != "" {
|
|
switch strings.ToLower(uiType) {
|
|
case "currency":
|
|
body["type"] = "number"
|
|
body["style"] = map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}
|
|
case "progress":
|
|
body["type"] = "number"
|
|
body["style"] = map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}
|
|
case "rating":
|
|
body["type"] = "number"
|
|
body["style"] = map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}
|
|
}
|
|
}
|
|
if property == nil {
|
|
return body, nil
|
|
}
|
|
property = cloneMap(property)
|
|
switch body["type"] {
|
|
case "number", "datetime", "created_at", "updated_at", "auto_number":
|
|
style, _ := body["style"].(map[string]interface{})
|
|
if style == nil {
|
|
style = map[string]interface{}{}
|
|
}
|
|
if inner, ok := property["style"].(map[string]interface{}); ok {
|
|
style = deepMergeMaps(style, inner)
|
|
delete(property, "style")
|
|
}
|
|
style = deepMergeMaps(style, property)
|
|
if len(style) > 0 {
|
|
body["style"] = style
|
|
}
|
|
case "select":
|
|
if options, ok := property["options"]; ok {
|
|
body["options"] = normalizeSelectOptions(options)
|
|
delete(property, "options")
|
|
}
|
|
if multiple, ok := property["multiple"].(bool); ok {
|
|
body["multiple"] = multiple
|
|
delete(property, "multiple")
|
|
}
|
|
body = deepMergeMaps(body, property)
|
|
case "user":
|
|
if multiple, ok := property["multiple"].(bool); ok {
|
|
body["multiple"] = multiple
|
|
delete(property, "multiple")
|
|
}
|
|
case "link":
|
|
if tableID, _ := property["table_id"].(string); tableID != "" {
|
|
body["link_table"] = tableID
|
|
delete(property, "table_id")
|
|
}
|
|
if tableID, _ := property["link_table"].(string); tableID != "" {
|
|
body["link_table"] = tableID
|
|
delete(property, "link_table")
|
|
}
|
|
if multiple, ok := property["multiple"].(bool); ok {
|
|
_ = multiple
|
|
delete(property, "multiple")
|
|
}
|
|
if backName, _ := property["back_field_name"].(string); backName != "" {
|
|
body["bidirectional"] = true
|
|
body["bidirectional_link_field_name"] = backName
|
|
delete(property, "back_field_name")
|
|
}
|
|
body = deepMergeMaps(body, property)
|
|
case "formula":
|
|
if expr, _ := property["formula_expression"].(string); expr != "" {
|
|
body["expression"] = expr
|
|
delete(property, "formula_expression")
|
|
}
|
|
if expr, _ := property["expression"].(string); expr != "" {
|
|
body["expression"] = expr
|
|
delete(property, "expression")
|
|
}
|
|
body = deepMergeMaps(body, property)
|
|
default:
|
|
body = deepMergeMaps(body, property)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{}, error) {
|
|
if rawFields != "" {
|
|
var fields []interface{}
|
|
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
|
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
|
|
}
|
|
return fields, nil
|
|
}
|
|
specs, err := parseNamedTypeSpecs(rawFieldSpecs, "field-specs")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fields := make([]interface{}, 0, len(specs))
|
|
for _, spec := range specs {
|
|
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
|
|
if err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
|
|
}
|
|
fields = append(fields, body)
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
func baseV3Path(parts ...string) string {
|
|
clean := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.Trim(part, "/")
|
|
if part != "" {
|
|
clean = append(clean, url.PathEscape(part))
|
|
}
|
|
}
|
|
return baseV3ServicePath + "/" + strings.Join(clean, "/")
|
|
}
|
|
|
|
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
return baseV3RawContext(runtime.Ctx(), runtime, method, path, params, data)
|
|
}
|
|
|
|
func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
queryParams := make(larkcore.QueryParams)
|
|
for k, v := range params {
|
|
switch val := v.(type) {
|
|
case []string:
|
|
for _, item := range val {
|
|
queryParams.Add(k, item)
|
|
}
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
queryParams.Add(k, fmt.Sprintf("%v", item))
|
|
}
|
|
default:
|
|
queryParams.Set(k, fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
req := &larkcore.ApiReq{
|
|
HttpMethod: strings.ToUpper(method),
|
|
ApiPath: path,
|
|
Body: data,
|
|
QueryParams: queryParams,
|
|
}
|
|
h := make(http.Header)
|
|
h.Set("X-App-Id", runtime.Config.AppID)
|
|
resp, err := runtime.DoAPIWithContext(ctx, req, larkcore.WithHeaders(h))
|
|
if err != nil {
|
|
return nil, baseAPIBoundaryError(err, "API call failed")
|
|
}
|
|
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
|
|
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
|
|
return nil, statusErr
|
|
}
|
|
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
|
|
}
|
|
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
|
|
var result map[string]interface{}
|
|
dec := json.NewDecoder(bytes.NewReader(body))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&result); err != nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
|
|
}
|
|
if result == nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
|
if result == nil || strings.TrimSpace(logID) == "" {
|
|
return
|
|
}
|
|
logID = strings.TrimSpace(logID)
|
|
if detail, ok := result["error"].(map[string]interface{}); ok {
|
|
if _, exists := detail["logid"]; !exists {
|
|
detail["logid"] = logID
|
|
}
|
|
return
|
|
}
|
|
data, _ := result["data"].(map[string]interface{})
|
|
if data == nil {
|
|
data = map[string]interface{}{}
|
|
result["data"] = data
|
|
}
|
|
detail, _ := data["error"].(map[string]interface{})
|
|
if detail == nil {
|
|
detail = map[string]interface{}{}
|
|
data["error"] = detail
|
|
}
|
|
if _, exists := detail["logid"]; !exists {
|
|
detail["logid"] = logID
|
|
}
|
|
}
|
|
|
|
func baseResponseLogID(resp *larkcore.ApiResp) string {
|
|
if resp == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
|
}
|
|
|
|
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
|
|
if resp == nil || resp.StatusCode < http.StatusBadRequest {
|
|
return nil
|
|
}
|
|
p, ok := errs.ProblemOf(classified)
|
|
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
|
return nil
|
|
}
|
|
body := strings.TrimSpace(string(resp.RawBody))
|
|
if resp.StatusCode >= http.StatusInternalServerError {
|
|
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
|
|
if logID := baseResponseLogID(resp); logID != "" {
|
|
err = err.WithLogID(logID)
|
|
}
|
|
return err
|
|
}
|
|
subtype := errs.SubtypeUnknown
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
subtype = errs.SubtypeNotFound
|
|
}
|
|
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
|
|
if logID := baseResponseLogID(resp); logID != "" {
|
|
err = err.WithLogID(logID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
result, err := baseV3Raw(runtime, method, path, params, data)
|
|
return handleBaseAPIResult(result, err, "API call failed")
|
|
}
|
|
|
|
func baseV3CallContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
result, err := baseV3RawContext(ctx, runtime, method, path, params, data)
|
|
return handleBaseAPIResult(result, err, "API call failed")
|
|
}
|
|
|
|
func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) {
|
|
result, err := baseV3Raw(runtime, method, path, params, data)
|
|
return handleBaseAPIResultAny(result, err, "API call failed")
|
|
}
|
|
|
|
func toInt(v interface{}) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case json.Number:
|
|
i, _ := n.Int64()
|
|
return int(i)
|
|
case string:
|
|
i, _ := strconv.Atoi(strings.TrimSpace(n))
|
|
return i
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func toStringSlice(v interface{}) []string {
|
|
arr, ok := v.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(arr))
|
|
for _, item := range arr {
|
|
if s, ok := item.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
|
|
if limit <= 0 {
|
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
|
}
|
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rawItems, _ := data["tables"].([]interface{})
|
|
if len(rawItems) == 0 {
|
|
rawItems, _ = data["items"].([]interface{})
|
|
}
|
|
if len(rawItems) == 0 {
|
|
if _, hasID := data["id"]; hasID {
|
|
rawItems = []interface{}{data}
|
|
}
|
|
}
|
|
items := make([]map[string]interface{}, 0, len(rawItems))
|
|
for _, item := range rawItems {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
items = append(items, m)
|
|
}
|
|
}
|
|
total := toInt(data["total"])
|
|
if total == 0 {
|
|
total = len(items)
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
|
if limit <= 0 {
|
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
|
}
|
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rawItems, _ := data["fields"].([]interface{})
|
|
items := make([]map[string]interface{}, 0, len(rawItems))
|
|
for _, item := range rawItems {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
items = append(items, m)
|
|
}
|
|
}
|
|
total := toInt(data["total"])
|
|
if total == 0 {
|
|
total = len(items)
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
|
if limit <= 0 {
|
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
|
}
|
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rawItems, _ := data["views"].([]interface{})
|
|
items := make([]map[string]interface{}, 0, len(rawItems))
|
|
for _, item := range rawItems {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
items = append(items, m)
|
|
}
|
|
}
|
|
total := toInt(data["total"])
|
|
if total == 0 {
|
|
total = len(items)
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
|
for _, field := range fields {
|
|
if ref == fieldID(field) || ref == fieldName(field) {
|
|
return field, nil
|
|
}
|
|
}
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
|
|
}
|
|
|
|
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
|
for _, table := range tables {
|
|
if ref == tableID(table) || ref == tableNameFromMap(table) {
|
|
return table, nil
|
|
}
|
|
}
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
|
|
}
|
|
|
|
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
|
for _, view := range views {
|
|
if ref == viewID(view) || ref == viewName(view) {
|
|
return view, nil
|
|
}
|
|
}
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
|
|
}
|
|
|
|
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
chunks := [][]map[string]interface{}{}
|
|
for start := 0; start < len(records); start += size {
|
|
end := start + size
|
|
if end > len(records) {
|
|
end = len(records)
|
|
}
|
|
chunks = append(chunks, records[start:end])
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
func chunkStringIDs(ids []string, size int) [][]string {
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
chunks := [][]string{}
|
|
for start := 0; start < len(ids); start += size {
|
|
end := start + size
|
|
if end > len(ids) {
|
|
end = len(ids)
|
|
}
|
|
chunks = append(chunks, ids[start:end])
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
func fieldName(field map[string]interface{}) string {
|
|
if v, _ := field["name"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := field["field_name"].(string)
|
|
return v
|
|
}
|
|
|
|
func fieldID(field map[string]interface{}) string {
|
|
if v, _ := field["id"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := field["field_id"].(string)
|
|
return v
|
|
}
|
|
|
|
func fieldTypeName(field map[string]interface{}) string {
|
|
if v, _ := field["type"].(string); v != "" {
|
|
return v
|
|
}
|
|
return fmt.Sprintf("%v", field["type"])
|
|
}
|
|
|
|
func tableID(table map[string]interface{}) string {
|
|
if v, _ := table["id"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := table["table_id"].(string)
|
|
return v
|
|
}
|
|
|
|
func tableNameFromMap(table map[string]interface{}) string {
|
|
if v, _ := table["name"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := table["table_name"].(string)
|
|
return v
|
|
}
|
|
|
|
func viewID(view map[string]interface{}) string {
|
|
if v, _ := view["id"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := view["view_id"].(string)
|
|
return v
|
|
}
|
|
|
|
func viewName(view map[string]interface{}) string {
|
|
if v, _ := view["name"].(string); v != "" {
|
|
return v
|
|
}
|
|
v, _ := view["view_name"].(string)
|
|
return v
|
|
}
|
|
|
|
func canonicalValue(v interface{}) string {
|
|
switch val := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case []interface{}:
|
|
if len(val) == 1 {
|
|
return canonicalValue(val[0])
|
|
}
|
|
case map[string]interface{}:
|
|
if id, ok := val["id"]; ok {
|
|
return canonicalValue(id)
|
|
}
|
|
if text, ok := val["text"]; ok {
|
|
return canonicalValue(text)
|
|
}
|
|
case string:
|
|
return strings.TrimSpace(val)
|
|
case float64:
|
|
if val == float64(int64(val)) {
|
|
return fmt.Sprintf("%d", int64(val))
|
|
}
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
|
|
var tuples []interface{}
|
|
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
|
|
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
|
|
}
|
|
result := make([]namedTypeSpec, 0, len(tuples))
|
|
for idx, item := range tuples {
|
|
pair, ok := item.([]interface{})
|
|
if !ok || len(pair) != 2 {
|
|
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
|
|
}
|
|
name, ok1 := pair[0].(string)
|
|
typeName, ok2 := pair[1].(string)
|
|
if !ok1 || !ok2 {
|
|
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
|
|
}
|
|
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type namedTypeSpec struct {
|
|
Name string
|
|
Type string
|
|
}
|
|
|
|
func selectRecordFields(items []map[string]interface{}, fields []string) []map[string]interface{} {
|
|
if len(fields) == 0 {
|
|
return items
|
|
}
|
|
result := make([]map[string]interface{}, 0, len(items))
|
|
for _, item := range items {
|
|
entry := map[string]interface{}{}
|
|
if recordID, _ := item["record_id"].(string); recordID != "" {
|
|
entry["record_id"] = recordID
|
|
}
|
|
selected := map[string]interface{}{}
|
|
fieldMap, _ := item["fields"].(map[string]interface{})
|
|
for _, name := range fields {
|
|
if value, ok := fieldMap[name]; ok {
|
|
selected[name] = value
|
|
}
|
|
}
|
|
entry["fields"] = selected
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func compareScalar(left interface{}, right interface{}) int {
|
|
lf, lerr := strconv.ParseFloat(canonicalValue(left), 64)
|
|
rf, rerr := strconv.ParseFloat(canonicalValue(right), 64)
|
|
if lerr == nil && rerr == nil {
|
|
switch {
|
|
case lf < rf:
|
|
return -1
|
|
case lf > rf:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
ls := canonicalValue(left)
|
|
rs := canonicalValue(right)
|
|
switch {
|
|
case ls < rs:
|
|
return -1
|
|
case ls > rs:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func asSet(v interface{}) map[string]bool {
|
|
set := map[string]bool{}
|
|
switch val := v.(type) {
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
set[canonicalValue(item)] = true
|
|
}
|
|
default:
|
|
if c := canonicalValue(v); c != "" {
|
|
set[c] = true
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func valueEmpty(v interface{}) bool {
|
|
switch val := v.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return strings.TrimSpace(val) == ""
|
|
case []interface{}:
|
|
return len(val) == 0
|
|
case map[string]interface{}:
|
|
return len(val) == 0
|
|
default:
|
|
return canonicalValue(v) == ""
|
|
}
|
|
}
|
|
|
|
func matchesCondition(value interface{}, condition []interface{}) bool {
|
|
if len(condition) < 2 {
|
|
return false
|
|
}
|
|
op, _ := condition[1].(string)
|
|
var target interface{}
|
|
if len(condition) > 2 {
|
|
target = condition[2]
|
|
}
|
|
switch op {
|
|
case "==":
|
|
return compareScalar(value, target) == 0
|
|
case "!=":
|
|
return compareScalar(value, target) != 0
|
|
case ">":
|
|
return compareScalar(value, target) > 0
|
|
case ">=":
|
|
return compareScalar(value, target) >= 0
|
|
case "<":
|
|
return compareScalar(value, target) < 0
|
|
case "<=":
|
|
return compareScalar(value, target) <= 0
|
|
case "empty":
|
|
return valueEmpty(value)
|
|
case "non_empty":
|
|
return !valueEmpty(value)
|
|
case "intersects":
|
|
left := asSet(value)
|
|
right := asSet(target)
|
|
for key := range left {
|
|
if right[key] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "disjoint":
|
|
left := asSet(value)
|
|
right := asSet(target)
|
|
for key := range left {
|
|
if right[key] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeFilterConfig(raw map[string]interface{}) (string, [][]interface{}) {
|
|
logic, _ := raw["logic"].(string)
|
|
if logic == "" {
|
|
logic, _ = raw["conjunction"].(string)
|
|
}
|
|
if logic == "" {
|
|
logic = "and"
|
|
}
|
|
rawConditions, _ := raw["conditions"].([]interface{})
|
|
conditions := make([][]interface{}, 0, len(rawConditions))
|
|
for _, item := range rawConditions {
|
|
switch cond := item.(type) {
|
|
case []interface{}:
|
|
conditions = append(conditions, cond)
|
|
case map[string]interface{}:
|
|
fieldName, ok := cond["field"]
|
|
if !ok {
|
|
fieldName = cond["field_name"]
|
|
}
|
|
conditions = append(conditions, []interface{}{fieldName, cond["operator"], cond["value"]})
|
|
}
|
|
}
|
|
return logic, conditions
|
|
}
|
|
|
|
func filterRecords(items []map[string]interface{}, filter map[string]interface{}) []map[string]interface{} {
|
|
logic, conditions := normalizeFilterConfig(filter)
|
|
if len(conditions) == 0 {
|
|
return items
|
|
}
|
|
result := make([]map[string]interface{}, 0, len(items))
|
|
for _, item := range items {
|
|
fields, _ := item["fields"].(map[string]interface{})
|
|
matches := logic != "or"
|
|
for _, cond := range conditions {
|
|
fieldRef := canonicalValue(cond[0])
|
|
value := fields[fieldRef]
|
|
matched := matchesCondition(value, cond)
|
|
if logic == "or" {
|
|
matches = matches || matched
|
|
} else {
|
|
matches = matches && matched
|
|
}
|
|
}
|
|
if matches {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeSortConfig(raw []interface{}) []map[string]interface{} {
|
|
result := make([]map[string]interface{}, 0, len(raw))
|
|
for _, item := range raw {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
entry := map[string]interface{}{}
|
|
if field, _ := m["field"].(string); field != "" {
|
|
entry["field"] = field
|
|
} else if field, _ := m["field_name"].(string); field != "" {
|
|
entry["field"] = field
|
|
}
|
|
if desc, ok := m["desc"].(bool); ok {
|
|
entry["desc"] = desc
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sortRecords(items []map[string]interface{}, sortConfig []interface{}) []map[string]interface{} {
|
|
normalized := normalizeSortConfig(sortConfig)
|
|
if len(normalized) == 0 {
|
|
return items
|
|
}
|
|
sorted := append([]map[string]interface{}{}, items...)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
leftFields, _ := sorted[i]["fields"].(map[string]interface{})
|
|
rightFields, _ := sorted[j]["fields"].(map[string]interface{})
|
|
for _, spec := range normalized {
|
|
fieldRef, _ := spec["field"].(string)
|
|
desc, _ := spec["desc"].(bool)
|
|
cmp := compareScalar(leftFields[fieldRef], rightFields[fieldRef])
|
|
if cmp == 0 {
|
|
continue
|
|
}
|
|
if desc {
|
|
return cmp > 0
|
|
}
|
|
return cmp < 0
|
|
}
|
|
return false
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
func sleepBetweenBatches(index int, total int) {
|
|
if index < total-1 {
|
|
time.Sleep(600 * time.Millisecond)
|
|
}
|
|
}
|