Files
larksuite__cli/shortcuts/base/helpers.go
xiaomi-bytedance 723f884e9b feat(base): support BaseApp application mode (#2231)
* 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>
2026-08-13 16:20:34 +08:00

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)
}
}