Files
larksuite__cli/shortcuts/base/helpers.go
Neseria f28a418019 feat(base): add --position and statistics number_format to dashboard-block create/update (#2118)
* feat(base): add --position and statistics number_format to dashboard-block create/update

Add an optional top-level --position flag ({x,y,w,h} JSON, parsed but not
coordinate-validated, passed through as a sibling of name/type/data_config) and
optional statistics data_config.number_format ({formatName,precision}) with
light enum + 0-9 integer validation. Both are backward compatible. Body
assembly is unified in a shared buildDashboardBlockBody helper so DryRun and
Execute stay isomorphic. Adds toIntStrict for strict precision parsing, focused
helper/execute/dry-run tests, an E2E dry-run test, and syncs the lark-base
dashboard + data-config skill references.

Co-authored-by: TRAE CLI <noreply@bytedance.com>

* fix(base): validate number_format on update path and add symmetry tests

The dashboard-block-update command parsed data_config but never ran the
statistics number_format check, so an illegal formatName/precision slipped
through locally while create rejected it — violating the SSOT + backend-design
§4.5 promise of CLI-side interception on BOTH paths. Update has no --type flag
(block type is immutable) and intentionally skips strong type validation, so it
now reuses the shared validateNumberFormat sub-validator that
validateBlockDataConfig delegates to, keeping create/update symmetric without
demanding table_name/series on a number_format-only update.

Also: add tests for the --no-validate bypass on create+update, a combined
update carrying position + number_format + name, and extend the DryRun/Execute
body isomorphism assertion to the update path. Clarify the --position flag Desc
that coordinate bounds are advisory (not validated locally or server-side) and
sync the lark-base SKILL.md routing table for --position / number_format.

Co-authored-by: TRAE CLI <noreply@bytedance.com>

* fix(base): align dashboard block validation paths

Validate dashboard block JSON consistently across dry-run and execute paths, enforce statistics number_format boundaries, and add layout precision workflow coverage and documentation.

* fix(base): resolve dashboard layout doc contradictions and harden isomorphism test

Follow-up to the --position / number_format feature, addressing review findings.

Docs (SSOT contradictions):
- SKILL.md:135 and lark-base-dashboard.md still told agents that dashboard
  shortcuts cannot set x/y/w/h and to offer auto-layout instead, which would
  have left --position unreachable through the skill. Both statements are now
  scoped to +dashboard-arrange, which genuinely cannot take coordinates.
- number_format was documented as supporting sub-field merge on update. That
  contradicts the update Tips and lark-base-dashboard.md's own data_config
  rule ("每个传入的字段内部是全量替换"). Documented as whole-key replacement
  and made the update Tip example carry formatName back.
- Trimmed both reference sections: dropped the duplicated field table, the
  restated validation blockquote, the standalone bash example and the 4-column
  comparison table; kept the enum table and the two load-bearing gotchas.
  Reformatted the example to the file's multi-line JSON style, and generalized
  the 场景 3 --position argument to '{...}' like its neighbours.

Tests:
- The isomorphism check called buildDashboardBlockBody twice with the same
  arguments, so it could never fail. Replaced with an end-to-end comparison of
  the --dry-run preview body against the body captured from Execute; verified
  it fails under single-path fault injection.
- The live workflow now updates to values distinct from the create call and
  asserts them on read-back, instead of asserting substrings that the created
  state already satisfied. Dropped the position read-back assertion: this
  iteration does not contract get to echo coordinates.
- Filled in the two missing --no-validate cells (create data-config, update
  position).

Cleanup:
- Deleted the inline DryRun closures; both commands now point at the
  dryRunDashboardBlock* functions, matching the DryRun: dryRunX convention used
  across the package and removing the second body-assembly site.
- Rewrote the update comment that referenced review-round codenames and an
  external design doc section to be self-contained.

* fix(base): keep dashboard dry-run previews free of empty identifiers

Wiring the block create/update commands to the shared dryRunDashboardBlock*
functions routed them through dryRunDashboardBase, which Set all three
identifiers unconditionally. A create preview has no block_id yet, so it began
advertising "block_id": "" — an argument that reads as failed to resolve.

Skip empty values in the shared helper rather than special-casing create, which
also clears the same pre-existing noise from the +dashboard-arrange preview.
Pinned with a test asserting a create preview carries base_token and
dashboard_id and no block_id.

* fix(base): require complete --position objects and close the arrange/position gap

Round-2 review follow-up. Three findings, all one-liners in effect, that
compounded into a real failure mode: an agent told to "move this chart to the
right half" could send a partial position, have it accepted, and silently
resize the block to nothing — with no coordinate read-back to diagnose it.

- --position now requires all four of x/y/w/h. The server fills missing
  coordinates with zero rather than leaving them alone, so a partial object is
  a resize disguised as a move. Only the object's shape is checked; coordinate
  VALUES stay unvalidated (out-of-range, negative and overlapping still pass
  through) as documented. The check is semantic, so --no-validate skips it
  while the JSON parse still runs — the same split the rest of this command
  pair already uses. Rejected the alternative of validating ranges too: that
  would contradict the documented dws-aligned pass-through contract.
- +dashboard-arrange's Tips now point at --position. The cross-reference was
  one-directional: create/update told agents about arrange, but arrange — the
  command an agent reaches for first when asked to "fix the layout" — never
  mentioned that exact placement had become possible.
- Documented that coordinates are write-only this iteration. The reference doc
  offered "replicate an existing dashboard's layout" as a use case while the
  PR itself scopes out coordinate read-back, sending agents to look for x/y/w/h
  that get/list do not return.

Also from the same review:
- The dry-run builders no longer discard buildDashboardBlockBody's error. It is
  unreachable while Validate parses the same flags first, but returning nil
  makes the runner fail loudly instead of previewing a body with a field
  silently missing.
- Added precision cases that run through the real command. The existing
  table-driven ones decode with UseNumber and hit toIntStrict's json.Number
  branch, which production never takes — parseJSONObject uses a plain
  json.Unmarshal, so precision always arrives as float64.
- coverage.md now says which four commands rest solely on the credential-gated
  live test that has not been executed yet.
- Marked the number_format fallback claim as unverified against the backend.

* fix(base): close the position guard's null hole and the contract drift it left behind

Round-3 review follow-up. Two of these were introduced by the previous
follow-up commit, not by the original feature.

- The --position completeness guard only asked whether the key was present,
  and a JSON null key IS present. `{"x":6,"y":null,"w":null,"h":null}` sailed
  through the very check meant to stop it — the exact scenario the guard's own
  comment describes. Each coordinate must now actually decode as a number, so
  null, strings, objects and bools are rejected alongside missing keys. This is
  still a shape check: out-of-range, negative and fractional values keep
  passing through as documented. The package's neighbours (`cfg["text"].(string)`,
  `table_name`) already validate required fields with a type assertion; this
  was the one place that did not. Mutation-verified: reverting the assertion
  turns the explicit-nulls case red.
- coverage.md claimed `+dashboard-block-get` "reads back position" while the
  test it cites deliberately stopped asserting coordinates — a line the
  previous commit invalidated and did not update. It now says number_format
  only. The `+dashboard-block-update` row also claimed dry-run coverage for
  number_format that only the unexecuted live test provides.
- dashboard-block-data-config.md still said the update path does no local
  validation, which commit bb7d8fbc made false in this same PR. An agent
  reading it would not expect exit 2 and might reach for --no-validate, which
  now also disables the position guard.

Also from that review:
- --no-validate's flag Desc only mentioned data_config; it silently covers the
  --position check too. Said so, in both commands.
- Four places stated unverified backend behaviour as fact — including a claim
  that the server zeroes missing coordinates, which was the guard's entire
  premise, and a "backend defaults to digital" line 23 lines above a blockquote
  saying that very fallback was unverified. All reworded to what is actually
  known; the guard's rationale is now stated in terms of the request we send.
- E2E dry-run assertions were whole-output substring matches (`"w": 6` could
  match anywhere); switched to clie2e.DryRunGet path assertions like the
  sibling suites, which also lets them prove position is a top-level sibling
  rather than nested in data_config.
- Documented that formatName is case-sensitive, unlike rollup which is
  normalized — same object, two conventions, worth saying out loud.
- The --position canonical rewrite's comment claimed it kept Validate/DryRun/
  Execute consistent; they re-parse anyway. Its real job is folding @file input
  inline so the two paths cannot read a changed file. Comment now says that.
- Named buildDashboardBlockBody's bool at the call sites; covered all three
  branches of the identifier skip, not just block_id.

* fix(base): stop dry-run previews leaking route templates; finish the unverified-claim sweep

Round-4 review follow-up. Both findings trace back to earlier follow-up commits
rather than the original feature, and both are the same failure shape: fixing
the instance instead of the class.

- 68bdaccd made dryRunDashboardBase skip empty identifiers, but Set() doubles as
  the substitution source for :param placeholders in the URL. Skipping a
  declared-but-empty identifier therefore printed the raw route template —
  `.../blocks/:block_id` — while also removing `"block_id": ""`, the one signal
  that told the caller their argument was empty. An agent whose `$BLOCK_ID` did
  not expand would see a preview that looks like the CLI failed to substitute,
  with nothing pointing at the real cause. The condition is now whether the
  command declares the flag, which is what the comment claimed all along: create
  genuinely has no block-id, and that is the case worth omitting.
  Not fixed here: a declared-but-empty required identifier still reaches the
  wire as a request to the collection endpoint (`baseV3Path` drops empty
  segments). That predates this PR and spans the whole base package — worth its
  own change rather than guarding two commands and leaving nine inconsistent.
- The isomorphism test only compared bodies, so a preview could target a
  different endpoint than Execute and still pass. It now compares method and URL
  as well, and rejects any leftover ":" placeholder — that is the mechanism that
  would have caught the above.
- 82f72540's message claimed all four unverified backend statements had been
  reworded; five survived, three of them in `--help`, where the --position Desc
  said server-side acceptance was unverified two lines above a Tip asserting
  overlaps are not server-checked. All five now match the wording already used
  in lark-base-dashboard.md, and the PR body Summary no longer contradicts its
  own Known limitations.

The rejected-alternative for the first item: guarding empty required identifiers
in Validate would be the root-cause fix, but applying it to the two commands
this PR owns while nine sibling dashboard commands keep the old behaviour trades
one inconsistency for another.

* fix(base): stabilize dashboard block validation inputs

* docs(base): clarify precise dashboard layout workflow

* docs(base): align dashboard live coverage status

* docs(base): soften absolute dashboard layout phrasing in skill

Replace "run exactly once / stop" wording for +dashboard-arrange and
--position with intent-based guidance (prefer whole-dashboard arrange,
generally no need to re-read position) so the skill routes agents away
from per-block churn and useless retries without forbidding legitimate
user-driven follow-up adjustments.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(base): clarify dashboard layout guidance

* docs(skills): move dashboard layout guidance to reference

* docs(base): verify dashboard number format defaults

---------

Co-authored-by: wanglei.75 <wanglei.75@bytedance.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-08-20 19:26:27 +08:00

1044 lines
29 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"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 toIntStrict(v interface{}) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case json.Number:
i, err := n.Int64()
if err != nil {
return 0, false
}
return int(i), true
case float64:
if math.IsNaN(n) || math.IsInf(n, 0) || n != math.Trunc(n) {
return 0, false
}
return int(n), true
default:
return 0, false
}
}
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)
}
}