mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
f28a418019
* 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>
1044 lines
29 KiB
Go
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)
|
|
}
|
|
}
|