mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
1183 lines
34 KiB
Go
1183 lines
34 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package base
|
||
|
||
import (
|
||
"bytes"
|
||
"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) {
|
||
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.DoAPI(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 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)
|
||
}
|
||
}
|
||
|
||
// ── Dashboard Block data_config normalization & validation ───────────
|
||
|
||
// normalizeDataConfig normalizes data_config fields for dashboard blocks.
|
||
// It converts series[].rollup to uppercase and group_by[].sort fields to lowercase.
|
||
func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||
if cfg == nil {
|
||
return nil
|
||
}
|
||
out := cloneMap(cfg)
|
||
// series[].rollup → 大写
|
||
if arr, ok := out["series"].([]interface{}); ok {
|
||
for i, it := range arr {
|
||
if m, ok := it.(map[string]interface{}); ok {
|
||
if r, ok := m["rollup"].(string); ok && r != "" {
|
||
m["rollup"] = strings.ToUpper(strings.TrimSpace(r))
|
||
}
|
||
arr[i] = m
|
||
}
|
||
}
|
||
out["series"] = arr
|
||
}
|
||
// group_by.sort 的 type/order → 小写
|
||
if gb, ok := out["group_by"].([]interface{}); ok {
|
||
for i, g := range gb {
|
||
if m, ok := g.(map[string]interface{}); ok {
|
||
if md, ok := m["mode"].(string); ok {
|
||
m["mode"] = strings.ToLower(strings.TrimSpace(md))
|
||
}
|
||
if sub, ok := m["sort"].(map[string]interface{}); ok {
|
||
if t, ok := sub["type"].(string); ok {
|
||
sub["type"] = strings.ToLower(strings.TrimSpace(t))
|
||
}
|
||
if o, ok := sub["order"].(string); ok {
|
||
sub["order"] = strings.ToLower(strings.TrimSpace(o))
|
||
}
|
||
m["sort"] = sub
|
||
}
|
||
gb[i] = m
|
||
}
|
||
}
|
||
out["group_by"] = gb
|
||
}
|
||
return out
|
||
}
|
||
|
||
// validateBlockDataConfig validates data_config based on block type.
|
||
// For text type, it checks for the presence of text field.
|
||
// For chart types, it validates table_name, series/count_all, group_by, and filter fields.
|
||
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
|
||
var errs []string
|
||
|
||
// text 类型特殊校验:只需要有 text 字段即可
|
||
if strings.ToLower(blockType) == "text" {
|
||
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
|
||
errs = append(errs, "text 类型组件缺少必填字段 text")
|
||
}
|
||
return errs
|
||
}
|
||
|
||
// 图表类型通用校验
|
||
// table_name 必填
|
||
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
|
||
errs = append(errs, "缺少必填字段 table_name")
|
||
}
|
||
// series 与 count_all 互斥且必有其一
|
||
_, hasSeries := cfg["series"]
|
||
_, hasCountAll := cfg["count_all"]
|
||
if !(hasSeries || hasCountAll) {
|
||
errs = append(errs, "series 与 count_all 二选一,至少提供其一")
|
||
}
|
||
if hasSeries && hasCountAll {
|
||
errs = append(errs, "series 与 count_all 互斥,不可同时存在")
|
||
}
|
||
// series 校验
|
||
if hasSeries {
|
||
arr, ok := cfg["series"].([]interface{})
|
||
if !ok || len(arr) == 0 {
|
||
errs = append(errs, "series 必须是非空数组")
|
||
} else {
|
||
// rollup 支持:SUM / MAX / MIN / AVERAGE(不支持 COUNTA;计数请使用 count_all)
|
||
allowed := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true}
|
||
for i, it := range arr {
|
||
m, ok := it.(map[string]interface{})
|
||
if !ok {
|
||
errs = append(errs, fmt.Sprintf("series[%d] 必须是对象", i))
|
||
continue
|
||
}
|
||
fn, _ := m["field_name"].(string)
|
||
if strings.TrimSpace(fn) == "" {
|
||
errs = append(errs, fmt.Sprintf("series[%d].field_name 不能为空", i))
|
||
}
|
||
r, _ := m["rollup"].(string)
|
||
r = strings.ToUpper(strings.TrimSpace(r))
|
||
if !allowed[r] {
|
||
errs = append(errs, fmt.Sprintf("series[%d].rollup 不在允许枚举内: %s", i, r))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// group_by 最多 2 个,字段名必填,sort 合法
|
||
if gb, ok := cfg["group_by"].([]interface{}); ok {
|
||
if len(gb) > 2 {
|
||
errs = append(errs, "group_by 最多支持 2 个维度")
|
||
}
|
||
for i, g := range gb {
|
||
m, ok := g.(map[string]interface{})
|
||
if !ok {
|
||
errs = append(errs, fmt.Sprintf("group_by[%d] 必须是对象", i))
|
||
continue
|
||
}
|
||
fn, _ := m["field_name"].(string)
|
||
if strings.TrimSpace(fn) == "" {
|
||
errs = append(errs, fmt.Sprintf("group_by[%d].field_name 不能为空", i))
|
||
}
|
||
if sub, ok := m["sort"].(map[string]interface{}); ok {
|
||
t, _ := sub["type"].(string)
|
||
t = strings.ToLower(strings.TrimSpace(t))
|
||
o, _ := sub["order"].(string)
|
||
o = strings.ToLower(strings.TrimSpace(o))
|
||
if t != "group" && t != "value" && t != "view" {
|
||
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
|
||
}
|
||
if o != "asc" && o != "desc" {
|
||
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// filter 基本结构
|
||
if f, ok := cfg["filter"].(map[string]interface{}); ok {
|
||
conj := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", f["conjunction"])))
|
||
if conj == "" {
|
||
conj = "and"
|
||
}
|
||
if conj != "and" && conj != "or" {
|
||
errs = append(errs, "filter.conjunction 仅支持 and|or")
|
||
}
|
||
if conds, ok := f["conditions"].([]interface{}); ok {
|
||
allowedOps := map[string]bool{"is": true, "isnot": true, "contains": true, "doesnotcontain": true, "isempty": true, "isnotempty": true, "isgreater": true, "isgreaterequal": true, "isless": true, "islessequal": true}
|
||
for i, it := range conds {
|
||
m, ok := it.(map[string]interface{})
|
||
if !ok {
|
||
errs = append(errs, fmt.Sprintf("filter.conditions[%d] 必须是对象", i))
|
||
continue
|
||
}
|
||
fn, _ := m["field_name"].(string)
|
||
if strings.TrimSpace(fn) == "" {
|
||
errs = append(errs, fmt.Sprintf("filter.conditions[%d].field_name 不能为空", i))
|
||
}
|
||
op, _ := m["operator"].(string)
|
||
key := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(op), " ", ""))
|
||
if !allowedOps[key] {
|
||
errs = append(errs, fmt.Sprintf("filter.conditions[%d].operator 不支持: %s", i, op))
|
||
}
|
||
if key != "isempty" && key != "isnotempty" {
|
||
if _, has := m["value"]; !has {
|
||
errs = append(errs, fmt.Sprintf("filter.conditions[%d].value 缺失", i))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return errs
|
||
}
|
||
|
||
func formatDataConfigErrors(problems []string) error {
|
||
if len(problems) == 0 {
|
||
return nil
|
||
}
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||
}
|