Files
larksuite-cli/shortcuts/base/helpers.go

1183 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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- "))
}