mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
23 Commits
v1.0.71
...
feat/get-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d7cb03eb | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
44514ad114 | ||
|
|
4a56748bfa | ||
|
|
0b6faa01bf | ||
|
|
1efe2dfb33 | ||
|
|
767386cb57 | ||
|
|
e71c76155e | ||
|
|
c363acf94e | ||
|
|
05285bb696 | ||
|
|
4c0f93bd6a | ||
|
|
76ebd49382 | ||
|
|
6c14c425fc | ||
|
|
27df16d3b2 | ||
|
|
47dc003601 | ||
|
|
4e0a6a988c | ||
|
|
5fb70d326a |
124
.github/workflows/ci.yml
vendored
124
.github/workflows/ci.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +9,12 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -295,6 +302,11 @@ jobs:
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -308,6 +320,23 @@ jobs:
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
@@ -341,16 +370,22 @@ jobs:
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -361,31 +396,68 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
id: build_cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
@@ -395,7 +467,7 @@ jobs:
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -472,8 +544,8 @@ jobs:
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
@@ -1527,6 +1552,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
|
||||
@@ -19,6 +19,29 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
|
||||
155
events/approval/preconsume.go
Normal file
155
events/approval/preconsume.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type approvalEventType string
|
||||
type approvalSubscriptionPath string
|
||||
|
||||
type approvalSubscriptionConfig struct {
|
||||
eventType approvalEventType
|
||||
subscribePath approvalSubscriptionPath
|
||||
}
|
||||
|
||||
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
eventType := string(cfg.eventType)
|
||||
subscribePath := string(cfg.subscribePath)
|
||||
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registered := make([]string, 0, len(subscriptionTypes))
|
||||
for _, subscriptionType := range subscriptionTypes {
|
||||
body := map[string]string{"subscription_type": subscriptionType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
|
||||
}
|
||||
registered = append(registered, subscriptionType)
|
||||
}
|
||||
|
||||
// Approval subscriptions are durable user-auth relations. Consuming events
|
||||
// should not cancel that relation when this local process exits.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
|
||||
raw := strings.TrimSpace(params["subscription_type"])
|
||||
if raw == "" {
|
||||
return append([]string(nil), approvalAllSubscriptionTypes...), nil
|
||||
}
|
||||
|
||||
values, err := parseApprovalSubscriptionTypeValues(raw)
|
||||
if err != nil {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(selected))
|
||||
for _, value := range approvalAllSubscriptionTypes {
|
||||
if selected[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strings.Split(raw, ","), nil
|
||||
}
|
||||
|
||||
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
|
||||
eventType,
|
||||
failed,
|
||||
)
|
||||
hint := fmt.Sprintf(
|
||||
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
|
||||
eventType,
|
||||
)
|
||||
if len(registered) > 0 {
|
||||
msg = fmt.Sprintf(
|
||||
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
hint = fmt.Sprintf(
|
||||
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = msg + ": " + upstream
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid subscription_type for EventKey %s: %q", eventType, value).
|
||||
WithParam("--param").
|
||||
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
eventType)
|
||||
}
|
||||
179
events/approval/register.go
Normal file
179
events/approval/register.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package approval registers Approval-domain EventKeys.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
|
||||
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
|
||||
|
||||
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
|
||||
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
|
||||
|
||||
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
|
||||
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
|
||||
)
|
||||
|
||||
var approvalAllSubscriptionTypes = []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
}
|
||||
|
||||
// Keys returns all Approval-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
DisplayName: "Approval instance status changed",
|
||||
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalInstanceStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:instance:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
|
||||
},
|
||||
{
|
||||
Key: eventTypeApprovalTaskStatusChangedV4,
|
||||
DisplayName: "Approval task status changed",
|
||||
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalTaskStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:task:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionParams() []event.ParamDef {
|
||||
return []event.ParamDef{
|
||||
{
|
||||
Name: "subscription_type",
|
||||
Type: event.ParamMulti,
|
||||
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
|
||||
Values: []event.ParamValue{
|
||||
{
|
||||
Value: approvalSubscriptionTypeInvolved,
|
||||
Desc: "Receive events where the current user is the approval requester or approver.",
|
||||
},
|
||||
{
|
||||
Value: approvalSubscriptionTypeManaged,
|
||||
Desc: "Receive events under approval definitions managed by the current user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
StartUser *ApprovalUserID `json:"start_user"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalInstanceStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
StartUser: envelope.Event.StartUser,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
TaskExternalID string `json:"task_external_id"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalTaskStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
TaskID: envelope.Event.TaskID,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
TaskExternalID: envelope.Event.TaskExternalID,
|
||||
AssignedUser: envelope.Event.AssignedUser,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
654
events/approval/register_test.go
Normal file
654
events/approval/register_test.go
Normal file
@@ -0,0 +1,654 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
type fakeAPIClient struct {
|
||||
calls []recordedCall
|
||||
err error
|
||||
errOnCall int
|
||||
}
|
||||
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func TestKeysApprovalMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
schemaType reflect.Type
|
||||
subscribe string
|
||||
}{
|
||||
{
|
||||
key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
scope: "approval:instance:read",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalInstancesSubscription,
|
||||
},
|
||||
{
|
||||
key: eventTypeApprovalTaskStatusChangedV4,
|
||||
scope: "approval:task:read",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalTasksSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
byKey := make(map[string]event.KeyDefinition, len(keys))
|
||||
for _, def := range keys {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
def, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %s", tc.key)
|
||||
}
|
||||
if def.EventType != tc.key {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
|
||||
}
|
||||
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process must flatten raw V2 envelopes")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume must subscribe approval user-auth events")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
|
||||
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
|
||||
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
|
||||
}
|
||||
assertSubscriptionParam(t, def.Params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
|
||||
t.Helper()
|
||||
if len(params) != 1 {
|
||||
t.Fatalf("len(params) = %d, want 1", len(params))
|
||||
}
|
||||
p := params[0]
|
||||
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, v := range p.Values {
|
||||
got[v.Value] = v.Desc
|
||||
}
|
||||
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
|
||||
if got[want] == "" {
|
||||
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reflectedApprovalSchema struct {
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type reflectedApprovalSchemaProperty struct {
|
||||
Format string `json:"format"`
|
||||
Enum []string `json:"enum"`
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
func TestApprovalSchemasAnnotations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaType reflect.Type
|
||||
eventType string
|
||||
statusValues []string
|
||||
userField string
|
||||
}{
|
||||
{
|
||||
name: "instance",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "start_user",
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "assigned_user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var schema reflectedApprovalSchema
|
||||
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
props := schema.Properties
|
||||
eventTypeEnum := props["type"].Enum
|
||||
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
|
||||
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
|
||||
}
|
||||
if got := props["timestamp"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("timestamp format = %v, want timestamp_ms", got)
|
||||
}
|
||||
assertEnumContains(t, props["status"].Enum, tc.statusValues)
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
userProps := props[tc.userField].Properties
|
||||
if got := userProps["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["union_id"].Format; got != "union_id" {
|
||||
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["user_id"].Format; got != "user_id" {
|
||||
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnumContains(t *testing.T, raw []string, wants []string) {
|
||||
t.Helper()
|
||||
got := make(map[string]bool, len(raw))
|
||||
for _, v := range raw {
|
||||
got[v] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !got[want] {
|
||||
t.Errorf("enum missing %q; enum=%v", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType string
|
||||
subscribePath string
|
||||
params map[string]string
|
||||
wantTypes []string
|
||||
}{
|
||||
{
|
||||
name: "instance omitted subscription_type registers both",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task explicit single managed",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
|
||||
wantTypes: []string{approvalSubscriptionTypeManaged},
|
||||
},
|
||||
{
|
||||
name: "task comma separated multi canonicalizes and deduplicates",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance json array multi",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: approvalEventType(tc.eventType),
|
||||
subscribePath: approvalSubscriptionPath(tc.subscribePath),
|
||||
})
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume returned error: %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(wantTypes) {
|
||||
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
|
||||
t.Helper()
|
||||
if got.method != wantMethod {
|
||||
t.Errorf("method = %q, want %q", got.method, wantMethod)
|
||||
}
|
||||
if got.path != wantPath {
|
||||
t.Errorf("path = %q, want %q", got.path, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(got.body, wantBody) {
|
||||
t.Errorf("body = %#v, want %#v", got.body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
|
||||
t.Run("nil runtime", func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil runtime error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("err = %T/%v, want typed internal error", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
|
||||
t.Run("invalid subscription type "+raw, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid subscription_type error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("invalid subscription_type should carry a hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
|
||||
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
})
|
||||
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial registration error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on registration error")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"registered subscription_type(s) [INVOLVED_APPROVAL]",
|
||||
"failed subscription_type MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Errorf("partial error message missing %q: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"already registered",
|
||||
"--param subscription_type=MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
|
||||
t.Fatalf("nil cause returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
upstream,
|
||||
)
|
||||
if err != upstream {
|
||||
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
|
||||
t.Errorf("message missing failed relation: %q", p.Message)
|
||||
}
|
||||
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
|
||||
cause := errors.New("transport closed")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
cause,
|
||||
)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
|
||||
t.Errorf("hint missing no-registration context: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
|
||||
out := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_001",
|
||||
"event_type": "approval.instance.status_changed_v4",
|
||||
"create_time": "1710000000000"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_001",
|
||||
"instance_code": "instance_code_001",
|
||||
"external_id": "external_001",
|
||||
"status": "PENDING",
|
||||
"operate_time": "1666079207003",
|
||||
"start_user": {
|
||||
"open_id": "ou_start",
|
||||
"union_id": "on_start",
|
||||
"user_id": "user_start"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
|
||||
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
|
||||
}
|
||||
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
|
||||
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
|
||||
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
|
||||
out := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_001",
|
||||
"event_type": "approval.task.status_changed_v4",
|
||||
"create_time": "1710000000001"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_002",
|
||||
"instance_code": "instance_code_002",
|
||||
"task_id": "task_001",
|
||||
"external_id": "external_002",
|
||||
"task_external_id": "task_external_001",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207004",
|
||||
"assigned_user": {
|
||||
"open_id": "ou_assignee",
|
||||
"union_id": "on_assignee",
|
||||
"user_id": "user_assignee"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
|
||||
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
|
||||
}
|
||||
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
|
||||
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
|
||||
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
|
||||
instance := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_fallback",
|
||||
"create_time": "1710000000002"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207005"
|
||||
}
|
||||
}`)
|
||||
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
|
||||
task := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_fallback",
|
||||
"create_time": "1710000000003"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"task_id": "task_fallback",
|
||||
"status": "DONE",
|
||||
"operate_time": "1666079207006"
|
||||
}
|
||||
}`)
|
||||
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
|
||||
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", processApprovalInstanceStatusChanged},
|
||||
{"task", processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.process(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process nil raw returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("Process nil raw output = %s, want nil", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalInstanceStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalTaskStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApprovalKeysRegisterCleanly(t *testing.T) {
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
}
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
42
events/approval/types.go
Normal file
42
events/approval/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
// ApprovalUserID identifies a user in the three Lark ID formats included by
|
||||
// approval status-change events.
|
||||
type ApprovalUserID struct {
|
||||
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
|
||||
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
|
||||
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
|
||||
}
|
||||
|
||||
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
|
||||
// approval.instance.status_changed_v4.
|
||||
type ApprovalInstanceStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
|
||||
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
|
||||
}
|
||||
|
||||
// ApprovalTaskStatusChangedV4Output is the flattened shape for
|
||||
// approval.task.status_changed_v4.
|
||||
type ApprovalTaskStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
|
||||
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
|
||||
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
@@ -14,12 +18,75 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). When the original invocation can be re-run safely,
|
||||
// the hint carries the complete retry command with --yes appended — eval
|
||||
// traces show agents always self-heal by appending --yes, so handing them
|
||||
// the exact line saves the reconstruction step. The retry line is omitted
|
||||
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
|
||||
// as its own token or bundled onto a flag as --flag=-, whose piped data a
|
||||
// bare re-run would not reproduce) or when the rendered command would be
|
||||
// unreasonably long to echo back.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
if retry := retryCommandWithYes(os.Args); retry != "" {
|
||||
return err.WithHint("add --yes to confirm; re-run: %s", retry)
|
||||
}
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
|
||||
// full invocation back (e.g. a +batch-update with a large inline JSON)
|
||||
// costs more context than it saves.
|
||||
const retryCommandMaxLen = 300
|
||||
|
||||
// retryCommandWithYes renders args as a shell-safe command line with --yes
|
||||
// appended, or "" when a safe rendering isn't possible (see
|
||||
// RequireConfirmation).
|
||||
func retryCommandWithYes(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, filepath.Base(args[0]))
|
||||
for _, a := range args[1:] {
|
||||
if argReadsStdin(a) {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, shellQuoteArg(a))
|
||||
}
|
||||
parts = append(parts, "--yes")
|
||||
line := strings.Join(parts, " ")
|
||||
if len(line) > retryCommandMaxLen {
|
||||
return ""
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// argReadsStdin reports whether an argument makes a flag read from stdin — the
|
||||
// portable bare "-" value, whether passed as its own token (--flag -) or
|
||||
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
|
||||
// re-run cannot reproduce, so any such argument suppresses the retry line.
|
||||
func argReadsStdin(a string) bool {
|
||||
if a == "-" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellQuoteArg single-quotes an argument when it contains any character a
|
||||
// POSIX shell could interpret, so the retry line is copy-paste safe.
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
// The hint may additionally carry a re-run line composed from the live
|
||||
// os.Args (environment-dependent under `go test`), but the add-yes
|
||||
// contract always leads.
|
||||
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
|
||||
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the retry line lives in
|
||||
// the free-text hint only; the typed protocol stays action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
|
||||
// basename argv[0], and the two omission guards (stdin args, oversized
|
||||
// commands).
|
||||
func TestRetryCommandWithYes(t *testing.T) {
|
||||
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{
|
||||
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
|
||||
"--url", "https://x.feishu.cn/sheets/tok",
|
||||
"--range", "A1:B2", "--sheet-name", "第 1 班",
|
||||
})
|
||||
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single quotes inside args survive", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
|
||||
if !strings.Contains(got, `'it'\''s'`) {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin arg omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
|
||||
t.Errorf("stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
|
||||
// --flag=- reads stdin the same as --flag -; both must suppress the line.
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
|
||||
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized command omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
|
||||
t.Errorf("oversized invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
@@ -77,7 +80,25 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// An absolute path under the system temp dir is read directly instead:
|
||||
// agents stage generated payloads (@/tmp/ops.json) there as a matter of
|
||||
// course, and the strict relative-to-cwd policy — load-bearing for uploads
|
||||
// and drive sync — only cost @file callers a python/stdin detour.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
resolved, terr := validate.SafeTempAbsInputPath(path)
|
||||
if terr == nil {
|
||||
data, err := os.ReadFile(resolved) //nolint:forbidigo // resolved is confined to the system temp dir by SafeTempAbsInputPath
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
// Absolute but outside the temp dir: surface the prescriptive error
|
||||
// (relative path / temp-dir path / stdin) instead of the generic
|
||||
// relative-only message the strict validator below would produce.
|
||||
return nil, fmt.Errorf("invalid file path %q: %w", path, terr)
|
||||
}
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute read path only when it resolves
|
||||
// under the system temp dir. Delegates to localfileio.SafeTempAbsInputPath.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
return localfileio.SafeTempAbsInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,10 +19,35 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. The one deliberate exception is the @file payload
|
||||
// expansion, which layers SafeTempAbsInputPath on top (see cmdutil).
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute READ path only when it resolves
|
||||
// under the canonical system temp dir. Agents stage generated payloads
|
||||
// (batch operations JSON, CSV) in /tmp as a matter of course, and rejecting
|
||||
// @/tmp/ops.json only pushed them through an extra python/stdin round trip
|
||||
// (recurring friction cluster in eval traces). Reads under os.TempDir()
|
||||
// carry no write risk and no project-escape risk. Errors for anything else
|
||||
// (relative paths included) — callers fall back to SafeInputPath semantics.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, "--file"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isAbsolutePath(path) {
|
||||
return "", fmt.Errorf("--file %q is not an absolute path", path)
|
||||
}
|
||||
resolved, ok := absPathUnderTempDir(path)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("--file must be a relative path within the current directory, or an absolute path under the system temp dir (%s), got %q (hint: use ./filename or a %s path; flags that support stdin can read any file via '-' instead)", os.TempDir(), path, os.TempDir())
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -96,6 +122,26 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// absPathUnderTempDir accepts an absolute path only when, after cleaning and
|
||||
// resolving symlinks (through the nearest existing ancestor for
|
||||
// not-yet-created files), it still lives under the canonical system temp dir.
|
||||
// A symlink inside the temp dir pointing outside it resolves outside and is
|
||||
// rejected.
|
||||
func absPathUnderTempDir(raw string) (string, bool) {
|
||||
canonicalTmp, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := resolveNearestAncestor(filepath.Clean(raw))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !isUnderDir(resolved, canonicalTmp) || resolved == canonicalTmp {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -185,15 +185,67 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; temp-dir reads go through SafeTempAbsInputPath instead
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTempAbsInputPath(t *testing.T) {
|
||||
t.Run("accepts a file under the temp dir", func(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "payload-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
resolved, err := SafeTempAbsInputPath(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected temp path accepted, got %v", err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
if resolved != canonical {
|
||||
t.Fatalf("resolved = %q, want %q", resolved, canonical)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects relative paths", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("./ops.json"); err == nil {
|
||||
t.Fatal("expected error for relative path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects absolute paths outside the temp dir", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("/etc/passwd"); err == nil {
|
||||
t.Fatal("expected error for non-temp absolute path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a temp-dir symlink escaping outside", func(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "escape-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
link := filepath.Join(dir, "escape.json")
|
||||
if err := os.Symlink("/etc/passwd", link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if _, err := SafeTempAbsInputPath(link); err == nil {
|
||||
t.Fatal("expected error for symlink escaping the temp dir, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.71",
|
||||
"version": "1.0.72",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
|
||||
in_permissions && /^[^[:space:]]/ { exit }
|
||||
in_permissions { print }
|
||||
' "$workflow")"
|
||||
workflow_concurrency="$(awk '
|
||||
/^concurrency:/ { in_concurrency = 1; print; next }
|
||||
in_concurrency && /^[^[:space:]]/ { exit }
|
||||
in_concurrency { print }
|
||||
' "$workflow")"
|
||||
fast_gate_section="$(job_section fast-gate)"
|
||||
unit_test_section="$(job_section unit-test)"
|
||||
lint_section="$(awk '
|
||||
@@ -46,6 +51,27 @@ results_section="$(awk '
|
||||
in_job { print }
|
||||
' "$workflow")"
|
||||
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
|
||||
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
|
||||
|
||||
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
|
||||
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
|
||||
echo "the supersession generation should match the PR-only run name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
|
||||
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
|
||||
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
|
||||
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
|
||||
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
|
||||
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
|
||||
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
|
||||
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
|
||||
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
|
||||
echo "e2e-live should use one repository-wide execution slot" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
|
||||
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "queue: max" <<<"$section"; then
|
||||
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "actions: read" <<<"$section"; then
|
||||
echo "e2e-live should use read-only Actions access for the supersession check" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
live_test_step="$(awk '
|
||||
/^ - name: Run CLI E2E tests/ { in_step = 1 }
|
||||
in_step { print }
|
||||
in_step && /^ - name: Publish CLI E2E test report/ { exit }
|
||||
' <<<"$section")"
|
||||
|
||||
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
|
||||
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required in \
|
||||
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
|
||||
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
|
||||
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
|
||||
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
|
||||
'::error::Superseded before live E2E started' \
|
||||
'exit 1'; do
|
||||
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
|
||||
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! awk '
|
||||
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
|
||||
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
|
||||
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
|
||||
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
|
||||
if (superseded_state != 3) exit 2
|
||||
superseded_closed = 1
|
||||
superseded_state = 0
|
||||
next
|
||||
}
|
||||
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
|
||||
END { exit superseded_closed && test_started ? 0 : 1 }
|
||||
' <<<"$live_test_step"; then
|
||||
echo "a superseded live run must stop before gotestsum starts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for output in \
|
||||
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
|
||||
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
|
||||
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
|
||||
if ! grep -Fq "$output" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should publish $output for the live job" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for validation_contract in \
|
||||
'case "$E2E_MODE" in' \
|
||||
'skip)' \
|
||||
'[ -z "$E2E_LIVE_PACKAGES" ]' \
|
||||
'full|subset)' \
|
||||
'[ -n "$E2E_LIVE_PACKAGES" ]' \
|
||||
'Invalid CLI E2E mode' \
|
||||
'exit 1'; do
|
||||
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! awk '
|
||||
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
|
||||
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
|
||||
END { if (!validated) exit 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should validate domain outputs before building" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
|
||||
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
|
||||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -272,16 +407,23 @@ if ! awk '
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
|
||||
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
|
||||
live_setup_step="$(awk -v name="$step_name" '
|
||||
$0 == " - name: " name { in_step = 1 }
|
||||
in_step { print }
|
||||
in_step && /^ - name:/ && $0 != " - name: " name { exit }
|
||||
' <<<"$section")"
|
||||
if grep -Eq '^ if:' <<<"$live_setup_step"; then
|
||||
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
|
||||
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
|
||||
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
|
||||
echo "e2e-live should fetch the tenant token via the dedicated script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "config init" <<<"$section"; then
|
||||
echo "e2e-live should use env credentials instead of config init"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
|
||||
echo "e2e-live should keep the bot app id under a test-only job env name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if awk '
|
||||
/^ e2e-live:/ { in_job = 1; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
|
||||
in_job && /^ env:/ { in_env = 1; next }
|
||||
in_env && /^ steps:/ { in_env = 0 }
|
||||
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
|
||||
END { exit found_standard_app_id ? 0 : 1 }
|
||||
' "$workflow"; then
|
||||
echo "e2e-live should not activate the env credential provider at job scope"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
|
||||
echo "e2e-live should pin the env credential brand to feishu"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if awk '
|
||||
/^ e2e-live:/ { in_job = 1; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
|
||||
in_job && /^ env:/ { in_env = 1; next }
|
||||
in_env && /^ steps:/ { in_env = 0 }
|
||||
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
|
||||
END { exit found_sensitive ? 0 : 1 }
|
||||
' "$workflow"; then
|
||||
echo "e2e-live should not expose live E2E credentials through job-level env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
|
||||
in_step && /id: live_e2e_tat/ { has_id = 1 }
|
||||
in_step && /^ if:/ { has_if = 1 }
|
||||
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
|
||||
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
|
||||
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
|
||||
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
|
||||
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
|
||||
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
echo "e2e-live should pass only a private tenant token file path through step output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Run CLI E2E tests/ { in_step = 1 }
|
||||
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
|
||||
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
|
||||
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
|
||||
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
|
||||
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
|
||||
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
|
||||
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
|
||||
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
|
||||
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
|
||||
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
|
||||
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
|
||||
in_step && /trap / { has_trap = 1 }
|
||||
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
|
||||
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should expose live E2E credentials only inside the test shell step"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
|
||||
echo "e2e-live should not retain an unreachable step-level skip branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
|
||||
fi
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$section" &&
|
||||
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
! grep -Fq "$fork_safe_guard" <<<"$section"; then
|
||||
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
164
scripts/fetch_e2e_tat.js
Normal file
164
scripts/fetch_e2e_tat.js
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
|
||||
//
|
||||
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
|
||||
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
|
||||
// and emits the file path as a step output so the test step can read it once
|
||||
// and then delete it.
|
||||
//
|
||||
// The secret arrives via environment variables; the OAuth parameter names are
|
||||
// literal because this is a source code file (.js), so the quality gate's
|
||||
// benign-code-credential exemption applies to the process.env references.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
|
||||
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
|
||||
const MAX_ATTEMPTS = 4;
|
||||
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
console.error(`::error::Missing required environment variable: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function postForm(url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === "http:" ? http : https;
|
||||
const req = transport.request(
|
||||
parsed,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
timeout: 20000,
|
||||
},
|
||||
(resp) => {
|
||||
const chunks = [];
|
||||
let settled = false;
|
||||
const rejectOnce = (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
resp.on("data", (chunk) => chunks.push(chunk));
|
||||
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
|
||||
resp.on("error", rejectOnce);
|
||||
resp.on("close", () => {
|
||||
if (!resp.complete) {
|
||||
rejectOnce(new Error("response closed before completion"));
|
||||
}
|
||||
});
|
||||
resp.on("end", () => {
|
||||
if (!resp.complete) {
|
||||
rejectOnce(new Error("response ended before completion"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({
|
||||
status: resp.statusCode,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
headers: resp.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("request timed out"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function encodeForm(params) {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchTenantToken() {
|
||||
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
|
||||
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
|
||||
|
||||
const body = encodeForm({
|
||||
grant_type: "client_credentials",
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
});
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(respBody);
|
||||
} catch {
|
||||
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
|
||||
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
|
||||
}
|
||||
if (payload) {
|
||||
const token = payload.access_token;
|
||||
if (status === 200 && payload.code === 0 && token) {
|
||||
return token;
|
||||
}
|
||||
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err.message;
|
||||
}
|
||||
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = await fetchTenantToken();
|
||||
console.log(`::add-mask::${token}`);
|
||||
|
||||
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
|
||||
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
|
||||
}
|
||||
|
||||
console.log("Prepared shared live E2E tenant token");
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeForm,
|
||||
fetchTenantToken,
|
||||
postForm,
|
||||
requireEnv,
|
||||
};
|
||||
203
scripts/fetch_e2e_tat.test.js
Normal file
203
scripts/fetch_e2e_tat.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
|
||||
|
||||
function startServer(handler) {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
handler(req, res, body);
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = server.address().port;
|
||||
resolve({ server, port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function abortResponse(res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "100",
|
||||
});
|
||||
res.write('{"code":0');
|
||||
setImmediate(() => res.destroy());
|
||||
}
|
||||
|
||||
function runScript(envOverrides) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
|
||||
const githubOutput = path.join(tmpDir, "github-output");
|
||||
const env = {
|
||||
...process.env,
|
||||
LARKSUITE_CLI_APP_ID: "test_app_id",
|
||||
TEST_BOT1_APP_SECRET: "test-secret",
|
||||
RUNNER_TEMP: tmpDir,
|
||||
GITHUB_OUTPUT: githubOutput,
|
||||
E2E_TAT_RETRY_BASE_MS: "10",
|
||||
...envOverrides,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data;
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data;
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
const output = fs.existsSync(githubOutput)
|
||||
? fs.readFileSync(githubOutput, "utf8")
|
||||
: "";
|
||||
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("encodeForm encodes form parameters", () => {
|
||||
const { encodeForm } = require(scriptPath);
|
||||
const result = encodeForm({
|
||||
grant_type: "client_credentials",
|
||||
client_id: "abc&def",
|
||||
client_secret: "test-secret",
|
||||
note: "x=y",
|
||||
});
|
||||
const params = new URLSearchParams(result);
|
||||
assert.equal(params.get("grant_type"), "client_credentials");
|
||||
assert.equal(params.get("client_id"), "abc&def");
|
||||
assert.equal(params.get("client_secret"), "test-secret");
|
||||
assert.equal(params.get("note"), "x=y");
|
||||
});
|
||||
|
||||
test("exits with error when app id is missing", async () => {
|
||||
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
|
||||
});
|
||||
|
||||
test("exits with error when app secret is missing", async () => {
|
||||
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
|
||||
});
|
||||
|
||||
test("fetches token and writes it to a private file", async () => {
|
||||
const { server, port } = await startServer((req, res, body) => {
|
||||
assert.equal(req.method, "POST");
|
||||
const params = new URLSearchParams(body);
|
||||
assert.equal(params.get("grant_type"), "client_credentials");
|
||||
assert.equal(params.get("client_id"), "test_app_id");
|
||||
assert.equal(params.get("client_secret"), "test-secret");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes("::add-mask::test-token"));
|
||||
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
|
||||
|
||||
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
|
||||
assert.ok(fs.existsSync(tatPath), "token file should exist");
|
||||
|
||||
const stat = fs.statSync(tatPath);
|
||||
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
|
||||
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes(`path=${tatPath}`),
|
||||
"should write path to GITHUB_OUTPUT",
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("retries an interrupted response and then succeeds", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
abortResponse(res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
|
||||
assert.equal(requestCount, 2);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("fails after every interrupted response is retried", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
abortResponse(res);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.equal(requestCount, 4);
|
||||
assert.match(result.stderr, /Failed to fetch tenant access token/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("exits with error after all retries fail", async () => {
|
||||
let requestCount = 0;
|
||||
const { server, port } = await startServer((req, res) => {
|
||||
requestCount++;
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: 500, error: "server error" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript({
|
||||
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.equal(requestCount, 4);
|
||||
assert.match(result.stderr, /Failed to fetch tenant access token/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
284
shortcuts/sheets/batch_key_vocab_test.go
Normal file
284
shortcuts/sheets/batch_key_vocab_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
|
||||
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"shortcut": shortcut, "input": input}
|
||||
}
|
||||
|
||||
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
|
||||
// off-vocabulary sub-op input key must error with a did-you-mean instead of
|
||||
// being silently ignored (silent ignore surfaced as misleading "missing
|
||||
// required flag" errors — the top batch error cluster in eval traces).
|
||||
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"rangee": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}), testToken, 0)
|
||||
ve := requireValidation(t, err, `unknown input key "rangee"`)
|
||||
if !strings.Contains(ve.Message, `did you mean "range"`) {
|
||||
t.Fatalf("message %q missing did-you-mean", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "input keys:") {
|
||||
t.Fatalf("hint %q missing key contract", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"dry_run": true,
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, `unknown input key "dry_run"`)
|
||||
})
|
||||
|
||||
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"spreadsheet-token": "shtXXX",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "do not pass input.spreadsheet-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
|
||||
// the declared flag, and the commandFlagAliases table (size → width/height on
|
||||
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
|
||||
// spelling, the single largest sub-op error cluster).
|
||||
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase sheetName resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A:C",
|
||||
"type": "pixel",
|
||||
"size": float64(120),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
width, _ := input["resize_width"].(map[string]interface{})
|
||||
if width["value"] != 120 {
|
||||
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "1:3",
|
||||
"type": "pixel",
|
||||
"size": float64(36),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" {
|
||||
t.Fatalf("range = %v, want A1:B2", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2", "C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "split them into 2 sub-ops")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
|
||||
// several invalid ops come back in a single error (each with its own
|
||||
// operations[i] context) instead of the first only — eval traces show
|
||||
// fix-one-resend loops of up to 7 round trips under first-error-only.
|
||||
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops both reported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
|
||||
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "--cells is required")
|
||||
if strings.Contains(ve.Message, "failed validation") {
|
||||
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
|
||||
// front-runs the server's mid-batch "does not match range" failures.
|
||||
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantContains string // "" = expect success
|
||||
}{
|
||||
{
|
||||
"empty cells prescribes +cells-clear",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
|
||||
"+cells-clear",
|
||||
},
|
||||
{
|
||||
"row count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 1 rows but --range \"A1:B3\" spans 3 rows",
|
||||
},
|
||||
{
|
||||
"column count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"has 1 columns but --range \"A1:B1\" spans 2 columns",
|
||||
},
|
||||
{
|
||||
"matching matrix passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 2 columns but --range \"A1\" spans 1 columns",
|
||||
},
|
||||
{
|
||||
"single-cell range with a single cell passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
|
||||
if tc.wantContains == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
|
||||
// prescription appended to server-side "N succeeded, M failed" errors.
|
||||
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrap := func(inner string) string {
|
||||
return `{"error":` + jsonQuote(inner) + `}`
|
||||
}
|
||||
|
||||
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`))
|
||||
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("msg %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`))
|
||||
if !strings.Contains(msg, "resend only the failed operations") {
|
||||
t.Fatalf("msg %q missing failed-only prescription", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero succeeded gets no note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`))
|
||||
if strings.Contains(msg, "no rollback") {
|
||||
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
|
||||
// the server double-encodes the inner error payload.
|
||||
func jsonQuote(s string) string {
|
||||
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -763,7 +763,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --writes plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range+cells each.
|
||||
if fv.Changed("writes") {
|
||||
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own atomic batch); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
|
||||
}
|
||||
return cellsSetInput(fv, token, sid, sname)
|
||||
}},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --ranges plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range each.
|
||||
if fv.Changed("ranges") {
|
||||
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own atomic batch); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
|
||||
}
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
@@ -301,6 +316,133 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
|
||||
// appear at a sub-op input's top level — their presence means the caller
|
||||
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
|
||||
// where the flattened flag keys belong. None of the batch sub-op translators
|
||||
// read input under these names, so rejecting them is safe.
|
||||
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
|
||||
|
||||
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
|
||||
// input may carry for `sc`: every non-system flag in flag-defs except the
|
||||
// spreadsheet locators (reserved for the batch top level). Nil when the
|
||||
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
|
||||
func subOpKeyVocabulary(sc string) map[string]bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
vocab := make(map[string]bool, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
vocab[df.Name] = true
|
||||
}
|
||||
return vocab
|
||||
}
|
||||
|
||||
// camelToKebab converts a lowerCamelCase key to its kebab form
|
||||
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
|
||||
// letter (nothing to convert).
|
||||
func camelToKebab(key string) string {
|
||||
if strings.ToLower(key) == key {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range key {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeSubOpInputKeys validates every sub-op input key against the
|
||||
// shortcut's flag vocabulary, rewriting habitual spellings in place and
|
||||
// rejecting anything that matches nothing. Eval traces show unknown keys were
|
||||
// previously ignored silently, which turned "wrong key" (size for width,
|
||||
// camelCase sheetName, an invented styles object) into misleading
|
||||
// "missing required flag" errors downstream — the single largest batch error
|
||||
// cluster. Rewrites applied, in order:
|
||||
//
|
||||
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
|
||||
// mapFlagView — accepted here as-is)
|
||||
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
|
||||
// - the command's intuitive-alias table (size → width/height on the resize
|
||||
// pair) — the same commandFlagAliases the cobra path applies
|
||||
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
|
||||
// array gets a split-into-sub-ops prescription instead
|
||||
//
|
||||
// Anything else errors with a did-you-mean. Returns a bare error; the caller
|
||||
// wraps it with the operations[i] (<shortcut>) context and key contract.
|
||||
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
|
||||
vocab := subOpKeyVocabulary(sc)
|
||||
if vocab == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(input))
|
||||
for k := range input {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
aliases := commandFlagAliases[sc]
|
||||
for _, k := range keys {
|
||||
hv := strings.ReplaceAll(k, "_", "-")
|
||||
if vocab[hv] {
|
||||
continue
|
||||
}
|
||||
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
|
||||
input[strings.ReplaceAll(kebab, "-", "_")] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
|
||||
if _, taken := input[target]; !taken {
|
||||
if _, taken := input[strings.ReplaceAll(target, "-", "_")]; !taken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
|
||||
if arr, isArr := input[k].([]interface{}); isArr {
|
||||
if len(arr) == 1 {
|
||||
if s, isStr := arr[0].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if s, isStr := input[k].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("unknown input key %q", k)
|
||||
display := make([]string, 0, len(vocab))
|
||||
for name := range vocab {
|
||||
display = append(display, strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
sort.Strings(display)
|
||||
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
@@ -312,6 +454,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - input 顶层出现 cell_styles / cell_merges / styles(误贴 MCP body 包裹结构)
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
@@ -335,7 +478,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
@@ -358,11 +501,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
// 连字符 / 下划线两种写法都算命中(spreadsheet-token 与 spreadsheet_token 同罪)。
|
||||
for userKey := range input {
|
||||
normalized := strings.ReplaceAll(userKey, "-", "_")
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if normalized == k {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, userKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
|
||||
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
|
||||
// the op input, but the op input is the shortcut's own flags flattened into
|
||||
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
|
||||
// an unrelated "at least one style flag is required" (helpers.go), which never
|
||||
// points at the real mistake.
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
@@ -373,6 +535,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
|
||||
// unknown key silently ignored surfaces later as a misleading
|
||||
// "missing required flag" error (the top batch error cluster in evals).
|
||||
if err := normalizeSubOpInputKeys(sc, input); err != nil {
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
@@ -410,7 +582,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
|
||||
// on one aggregated --operations error, mirroring the schema validator's
|
||||
// display cap.
|
||||
const batchOpErrorDisplayLimit = 5
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
|
||||
// 一次性返回(不再 fail-fast)——agent 一轮就能修完所有坏 op,而不是
|
||||
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
@@ -422,10 +601,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
var opErrs []error
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opErrs = append(opErrs, err)
|
||||
continue
|
||||
}
|
||||
if len(opErrs) > 0 {
|
||||
continue // already failing — keep scanning for more bad ops, skip cell math.
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
@@ -435,7 +619,27 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
switch len(opErrs) {
|
||||
case 0:
|
||||
return out, nil
|
||||
case 1:
|
||||
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
|
||||
}
|
||||
shown := opErrs
|
||||
truncated := false
|
||||
if len(shown) > batchOpErrorDisplayLimit {
|
||||
shown = shown[:batchOpErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, e.Error()))
|
||||
}
|
||||
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
|
||||
}
|
||||
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
|
||||
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
|
||||
// regions fan into ONE atomic batch_update, each item self-carrying its
|
||||
// sheet selector (no top-level fallback — same convention as +batch-update
|
||||
// sub-ops and +styles-put items), with per-item errors aggregated.
|
||||
func TestCellsSetWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writes := func(items string, extra ...string) (string, string, error) {
|
||||
args := append([]string{
|
||||
"--url", testURL, "--dry-run", "--writes", items,
|
||||
}, extra...)
|
||||
return runShortcutCapturingErr(t, CellsSet, args)
|
||||
}
|
||||
|
||||
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item without sheet selector errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
requireValidation(t, err, "sheet-id or --sheet-name")
|
||||
})
|
||||
|
||||
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--sheet-name", "S1")
|
||||
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
|
||||
})
|
||||
|
||||
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--range", "A1")
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("per-item errors aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both items pass the --writes schema (range+cells present) but fail
|
||||
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
|
||||
_, _, err := writes(`[
|
||||
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
|
||||
{"range":"C1","cells":[[{"value":"y"}]]}
|
||||
]`)
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("camelCase sheetName must normalize: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "S1") {
|
||||
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot nest inside batch-update", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"writes": []interface{}{map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
|
||||
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
|
||||
// bare shortcut var — mirror the real CLI wiring.
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL, "--dry-run",
|
||||
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--styles", `{"styles":[]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "unknown flag")
|
||||
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
|
||||
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
150
shortcuts/sheets/chart_examples.go
Normal file
150
shortcuts/sheets/chart_examples.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── +chart-create --print-example ─────────────────────────────────────
|
||||
//
|
||||
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
|
||||
// traces show agents paging through the full --print-schema dump for every
|
||||
// chart (25 round trips in one 35-task batch) and still missing deep
|
||||
// required fields. A ready-to-edit minimal template per chart type answers
|
||||
// the actual question ("what does a valid payload look like") in one local
|
||||
// call. Wired through PostMount, same pattern as +csv-put's flag-group
|
||||
// tweaks — no framework change.
|
||||
//
|
||||
// Templates mirror the canonical examples in the lark-sheets-chart
|
||||
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
|
||||
// inline headerMode with refs covering the header row, 1-based indices,
|
||||
// quoted sheet prefix in refs.
|
||||
|
||||
var chartExampleTemplates = map[string]string{
|
||||
"column": chartSimpleExample("column"),
|
||||
"bar": chartSimpleExample("bar"),
|
||||
"line": chartSimpleExample("line"),
|
||||
"area": chartSimpleExample("area"),
|
||||
"radar": chartSimpleExample("radar"),
|
||||
"scatter": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": "scatter"}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B20"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"pie": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 450},
|
||||
"snapshot": {
|
||||
"title": {"text": "占比标题"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "pie",
|
||||
"series": [{
|
||||
"index": 1,
|
||||
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B11"}],
|
||||
"dim1": {"serie": {"index": 1, "aggregate": true}},
|
||||
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"combo": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 700, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "柱线组合"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "combo",
|
||||
"series": [
|
||||
{"index": 2, "comboType": "column"},
|
||||
{"index": 3, "comboType": "line"}
|
||||
]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C13"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
// chartSimpleExample renders the shared minimal shape for plot types that
|
||||
// need nothing beyond plot.type (column / bar / line / area / radar).
|
||||
func chartSimpleExample(typ string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": %q}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C10"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`, typ)
|
||||
}
|
||||
|
||||
func chartExampleTypes() []string {
|
||||
types := make([]string, 0, len(chartExampleTemplates))
|
||||
for t := range chartExampleTemplates {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// withChartPrintExample wraps +chart-create's PostMount so the command grows
|
||||
// a --print-example flag that short-circuits execution and prints a minimal
|
||||
// ready-to-edit --properties template — purely local, no identity or
|
||||
// network. --properties' cobra-level required annotation is relaxed (the
|
||||
// input builder still enforces it on the real path, same trick as
|
||||
// +csv-put's --csv).
|
||||
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().String("print-example", "",
|
||||
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
|
||||
// Only --properties carries a cobra-level required annotation (the
|
||||
// locator flags are xor pairs, enforced later); the input builder
|
||||
// still errors "--properties is required" on the real path.
|
||||
if fl := cmd.Flags().Lookup("properties"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
prevRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
typ, _ := c.Flags().GetString("print-example")
|
||||
if typ == "" {
|
||||
return prevRunE(c, args)
|
||||
}
|
||||
tmpl, ok := chartExampleTemplates[typ]
|
||||
if !ok {
|
||||
return common.ValidationErrorf("no example for chart type %q; available: %s",
|
||||
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
|
||||
}
|
||||
fmt.Fprintln(c.OutOrStdout(), tmpl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
63
shortcuts/sheets/chart_examples_test.go
Normal file
63
shortcuts/sheets/chart_examples_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChartPrintExample pins the --print-example contract: a known type
|
||||
// prints its template and skips execution entirely; an unknown type lists
|
||||
// the available ones.
|
||||
func TestChartPrintExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prints template without locator flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
parent, _, _, _ := newTestRig(t, sc)
|
||||
var buf bytes.Buffer
|
||||
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
|
||||
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("print-example should run standalone, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"sectors"`) {
|
||||
t.Errorf("pie template should carry sectors, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type lists available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
|
||||
ve := requireValidation(t, err, `no example for chart type "donut"`)
|
||||
if !strings.Contains(ve.Message, "pie") {
|
||||
t.Errorf("message should list available types, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
|
||||
// template against the embedded chart-create properties schema — a template
|
||||
// the CLI itself would reject is worse than none.
|
||||
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
for typ, tmpl := range chartExampleTemplates {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
|
||||
t.Fatalf("template is not valid JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
|
||||
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
|
||||
t.Errorf("template rejected by embedded schema: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -821,12 +821,10 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)",
|
||||
"default": "none",
|
||||
"desc": "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.",
|
||||
"enum": [
|
||||
"before",
|
||||
"after",
|
||||
"none"
|
||||
"after"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -887,8 +885,19 @@
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"
|
||||
"required": "xor",
|
||||
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"
|
||||
},
|
||||
{
|
||||
"name": "ranges",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "yes",
|
||||
@@ -1277,13 +1286,14 @@
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Comma-separated info categories to include",
|
||||
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
|
||||
"enum": [
|
||||
"value",
|
||||
"formula",
|
||||
"style",
|
||||
"comment",
|
||||
"data_validation"
|
||||
"data_validation",
|
||||
"truncation"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1291,15 +1301,29 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "skip-hidden",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
"desc": "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"
|
||||
},
|
||||
{
|
||||
"name": "skip-filter",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -1392,17 +1416,24 @@
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"
|
||||
"required": "optional",
|
||||
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"
|
||||
},
|
||||
{
|
||||
"name": "max-chars",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "include-row-prefix",
|
||||
"kind": "own",
|
||||
@@ -1465,6 +1496,21 @@
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
|
||||
},
|
||||
{
|
||||
"name": "max-chars",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"kind": "own",
|
||||
@@ -1691,33 +1737,44 @@
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
"desc": "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Write range (A1 notation)"
|
||||
"required": "xor",
|
||||
"desc": "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"
|
||||
},
|
||||
{
|
||||
"name": "cells",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"required": "xor",
|
||||
"desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "writes",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allow-overwrite",
|
||||
"kind": "own",
|
||||
@@ -2787,6 +2844,43 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+styles-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator (target sheets are named inside --styles items)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the batched request template for each expanded operation; no network side effects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+batch-update": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
@@ -2809,7 +2903,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.",
|
||||
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
|
||||
@@ -648,6 +648,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"writes": {
|
||||
"type": "array",
|
||||
"description": "多区域写入项数组(最多 100 项),整批单次原子提交;支持跨 sheet。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"range",
|
||||
"cells"
|
||||
],
|
||||
"properties": {
|
||||
"sheet_id": {
|
||||
"type": "string",
|
||||
"description": "目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
|
||||
},
|
||||
"sheet_name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)。"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cells-set-style": {
|
||||
@@ -7748,87 +7777,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+styles-put": {
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
@@ -7856,12 +7805,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8055,7 +8008,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8073,19 +8026,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8104,8 +8070,395 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -8228,12 +8581,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8427,7 +8784,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8445,19 +8802,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8476,8 +8846,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
@@ -75,9 +75,11 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"},
|
||||
{Name: "skip-filter", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -133,10 +135,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
@@ -316,8 +319,9 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
@@ -344,7 +348,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -392,7 +397,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -975,6 +980,15 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+styles-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -983,6 +997,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
|
||||
@@ -38,9 +38,104 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
chainFlagAliases(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
|
||||
//
|
||||
// Eval traces show unknown-flag failures cluster on a handful of habitual
|
||||
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
|
||||
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
|
||||
// the enum-normalization contract above: a name whose value semantics are
|
||||
// identical to the real flag is rewritten silently (zero round-trips); a
|
||||
// name whose fix changes the value or moves it into a JSON field gets a
|
||||
// curated prescription on the unknown-flag error instead — never a silent
|
||||
// rewrite.
|
||||
|
||||
// commandFlagAliases maps, per command, habitual flag names onto the flag
|
||||
// actually registered. Only pairs with identical value semantics belong
|
||||
// here: the rewrite is invisible, so it must be safe to apply unread
|
||||
// (+csv-put --file with a path value still trips the file-path guard, which
|
||||
// prescribes @file / stdin).
|
||||
var commandFlagAliases = map[string]map[string]string{
|
||||
"+csv-put": {"file": "csv"},
|
||||
"+sheet-create": {"name": "title"},
|
||||
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
|
||||
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
|
||||
// here too — the rename is the single largest sub-op error cluster in
|
||||
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
|
||||
"+cols-resize": {"cols": "range", "size": "width"},
|
||||
"+rows-resize": {"rows": "range", "size": "height"},
|
||||
"+range-fill": {"source": "source-range", "target": "target-range"},
|
||||
"+range-copy": {"source": "source-range", "target": "target-range"},
|
||||
"+range-move": {"source": "source-range", "target": "target-range"},
|
||||
}
|
||||
|
||||
// intuitiveFlagHints carries the prescription for habitual names whose fix
|
||||
// is not a 1:1 rename — the value belongs to a different flag or to a field
|
||||
// inside a JSON payload. The hint spells the exact correct form so the
|
||||
// retry needs no --help round trip.
|
||||
var intuitiveFlagHints = map[string]map[string]string{
|
||||
"+sheet-copy": {
|
||||
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
},
|
||||
"+dim-insert": {
|
||||
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
|
||||
},
|
||||
"+dim-freeze": {
|
||||
"frozen-rows": "freeze the first N rows with --dimension row --count N",
|
||||
"frozen-cols": "freeze the first N columns with --dimension column --count N",
|
||||
"frozen-columns": "freeze the first N columns with --dimension column --count N",
|
||||
},
|
||||
"+cells-set-style": {
|
||||
"bold": "use --font-weight bold",
|
||||
"italic": "use --font-style italic",
|
||||
"underline": "use --font-line underline",
|
||||
},
|
||||
"+cells-set": {
|
||||
// Predictable prior from +table-put --styles: models will try to
|
||||
// attach range-level styling to a --writes call the same way.
|
||||
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
|
||||
},
|
||||
"+table-put": {
|
||||
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
|
||||
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
},
|
||||
}
|
||||
|
||||
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
|
||||
// (on top of any hook a prior PostMount installed, e.g. --token →
|
||||
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
|
||||
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
|
||||
// canonical name), and the command's intuitive-alias table. Either way a
|
||||
// habitual name parses as the real flag with zero round trips. Aliases
|
||||
// never shadow a registered flag and never appear in --help; an alias whose
|
||||
// target vanished (spec-side rename) is dropped, degrading to the
|
||||
// unknown-flag prescription.
|
||||
func chainFlagAliases(cmd *cobra.Command) {
|
||||
aliases := commandFlagAliases[cmd.Name()]
|
||||
usable := make(map[string]string, len(aliases))
|
||||
for alias, target := range aliases {
|
||||
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
|
||||
usable[alias] = target
|
||||
}
|
||||
}
|
||||
prev := cmd.Flags().GetNormalizeFunc()
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if strings.Contains(name, "_") {
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
}
|
||||
if target, ok := usable[name]; ok {
|
||||
name = target
|
||||
}
|
||||
return prev(fs, name)
|
||||
})
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
@@ -50,6 +145,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
|
||||
// top-level sheet locator (each sub-op names its own sheet inside its input),
|
||||
// yet agents reach for --sheet-id / --sheet-name at the top level. An
|
||||
// edit-distance suggestion would only mislead here, so skip it and name the
|
||||
// real contract instead. Underscore spellings (--sheet_id) are matched too:
|
||||
// the error message itself teaches the underscore key names, and sub-op
|
||||
// inputs accept them, so agents mix the two styles.
|
||||
locatorName := strings.ReplaceAll(name, "_", "-")
|
||||
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
|
||||
}
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
@@ -67,6 +175,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
// A curated prescription beats both: it spells the exact correct form
|
||||
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
|
||||
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
|
||||
hint = rx
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = rx + "; valid flags: " + list
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
@@ -139,6 +255,16 @@ var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
|
||||
// from the API docs; lowercased by canonicalEnumValue before lookup.
|
||||
"merge_all": "all",
|
||||
"merge_rows": "rows",
|
||||
"merge_columns": "columns",
|
||||
// Boolean-style word-wrap habits: true unambiguously means wrap on;
|
||||
// false means "don't wrap", whose Lark default is overflow (word-clip is
|
||||
// a distinct truncation mode nobody spells "false").
|
||||
"true": "auto-wrap",
|
||||
"false": "overflow",
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
|
||||
@@ -96,6 +96,55 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
|
||||
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
|
||||
// the per-op locator contract instead of offering a misleading fuzzy guess.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
|
||||
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
|
||||
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
|
||||
}
|
||||
if len(verr.Params[0].Suggestions) != 0 {
|
||||
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
|
||||
// special case is scoped to the two sheet-locator flags: any other unknown
|
||||
// flag on +batch-update keeps the normal did-you-mean behaviour.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if strings.Contains(verr.Message, "no top-level sheet locator") {
|
||||
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
@@ -284,9 +333,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--col-size", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
@@ -294,3 +343,191 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
|
||||
// habitual name with identical value semantics parses as the real flag on a
|
||||
// mounted command, costing zero round trips (eval: --cols, --file, --name,
|
||||
// --source/--target each burned an unknown-flag failure plus a --help call).
|
||||
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--width", "100",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "A:D") {
|
||||
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-create")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--name", "汇总",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "汇总") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+range-fill")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--source", "B2",
|
||||
"--target", "B3:B10",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{"B2", "B3:B10"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+csv-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--start-cell", "A1",
|
||||
"--file", "a,b\n1,2",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "a,b") {
|
||||
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A:C",
|
||||
"--size", "120",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "120") {
|
||||
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+rows-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "1:3",
|
||||
"--size", "36",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias never shadows a registered flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+csv-put"}
|
||||
c.Flags().String("csv", "", "")
|
||||
c.Flags().String("file", "", "") // hypothetical real flag wins
|
||||
chainFlagAliases(c)
|
||||
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("file"); got != "x" {
|
||||
t.Errorf("registered --file should keep its own value, got %q", got)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
|
||||
// names whose fix is not a rename answer with the exact correct form, so the
|
||||
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
|
||||
// --help calls, +dim-insert kept failing even after reading help).
|
||||
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
command string
|
||||
args []string
|
||||
wrong string
|
||||
wantHint []string
|
||||
}{
|
||||
{
|
||||
command: "+dim-insert",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
|
||||
wrong: "--dimension",
|
||||
wantHint: []string{"--position", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
|
||||
wrong: "--frozen-rows",
|
||||
wantHint: []string{"--dimension row --count N"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
|
||||
wrong: "--bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+sheet-copy",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
|
||||
wrong: "--new-sheet-name",
|
||||
wantHint: []string{"--title", "source sheet"},
|
||||
},
|
||||
{
|
||||
command: "+table-put",
|
||||
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
|
||||
wrong: "--start-cell",
|
||||
wantHint: []string{`"start_cell"`, "+csv-put"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, tc.command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
|
||||
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
|
||||
for _, want := range tc.wantHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
//
|
||||
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
||||
// first segment names the flag, the rest walk the schema's properties
|
||||
// (descending through array items implicitly), returning just that
|
||||
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
||||
// lines — otherwise force agents to page through the full dump for one
|
||||
// nested field; eval traces show 25 such round trips in one batch.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
name, path := splitSchemaPath(flagName)
|
||||
schema, ok := entry[name]
|
||||
if !ok {
|
||||
// Tolerate the wire-vocabulary underscore form (--flag-name
|
||||
// border_styles for border-styles) — agents copy field names out
|
||||
// of JSON payloads where underscores are canonical.
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
schema, ok = entry[alt]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) > 0 {
|
||||
pretty, err = sliceSchemaByPath(pretty, name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
|
||||
// splitSchemaPath splits a --flag-name value into the flag name and the
|
||||
// optional dotted schema path after it.
|
||||
func splitSchemaPath(flagName string) (string, []string) {
|
||||
parts := strings.Split(flagName, ".")
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
||||
// Each segment matches a key under "properties"; array levels are descended
|
||||
// implicitly through "items" (an explicit "items" segment also works), and
|
||||
// oneOf branches are searched for the first one carrying the key. A miss
|
||||
// errors with the keys actually available at that level so the caller can
|
||||
// re-issue the path without a full dump.
|
||||
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
||||
node := schema
|
||||
walked := flagName
|
||||
for _, seg := range path {
|
||||
next, ok := schemaChild(node, seg)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
node = next
|
||||
walked += "." + seg
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// schemaChild resolves one path segment against a schema node, descending
|
||||
// through items / oneOf wrappers as needed.
|
||||
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
||||
for depth := 0; depth < 8; depth++ {
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if seg == "items" {
|
||||
if items, ok := m["items"]; ok {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
if child, ok := props[seg]; ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
node = items
|
||||
continue
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
if child, ok := schemaChild(b, seg); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// schemaChildKeys lists the property keys reachable at a schema node (through
|
||||
// items / oneOf wrappers), for the path-miss error.
|
||||
func schemaChildKeys(node interface{}) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var collect func(n interface{}, depth int)
|
||||
collect = func(n interface{}, depth int) {
|
||||
if depth > 8 {
|
||||
return
|
||||
}
|
||||
m, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
for k := range props {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
collect(items, depth+1)
|
||||
return
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
collect(b, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(node, 0)
|
||||
keys := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, &schema, "", c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
vErr := c.errs[0]
|
||||
if len(c.errs) == 1 {
|
||||
// Single failure keeps the historical message byte-for-byte.
|
||||
//
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
@@ -107,18 +117,63 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// branch means entry[name] resolved a schema from the embedded
|
||||
// index, so the suggested command is guaranteed to print it.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
isTypeMismatch := errors.As(vErr, &tm)
|
||||
if isTypeMismatch && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
||||
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
||||
}
|
||||
}
|
||||
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
|
||||
// address the actual field), but if the field itself carries an enum /
|
||||
// description, append that one line — same "fix on first retry" goal.
|
||||
msg := vErr.Error()
|
||||
if isTypeMismatch {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
name, msg, command, name).WithCause(vErr)
|
||||
}
|
||||
return nil
|
||||
// Multiple failures: report them all at once (numbered, each with its
|
||||
// own inline teaching hint) so the agent fixes the whole payload in one
|
||||
// retry instead of the fail-fast "fix one, hit the next" loop.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
|
||||
}
|
||||
|
||||
// formatSchemaErrorList renders collected failures as a numbered one-line
|
||||
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
|
||||
// their enum/description suffix just like the single-error path. Entries
|
||||
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
|
||||
// collector stops at cap, so the exact total is unknown by design.
|
||||
func formatSchemaErrorList(errs []error) string {
|
||||
shown := errs
|
||||
truncated := false
|
||||
if len(shown) > schemaErrorDisplayLimit {
|
||||
shown = shown[:schemaErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
msg := e.Error()
|
||||
var tm *typeMismatchError
|
||||
if errors.As(e, &tm) {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
|
||||
}
|
||||
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
@@ -187,8 +242,10 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
// data/flag-schemas.json. Description is retained (not just documentation)
|
||||
// so a required-missing or type-mismatch error can inline the one-line
|
||||
// field doc — the agent then fixes the input without a --print-schema round
|
||||
// trip. Other unknown keys stay dropped.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
@@ -204,6 +261,7 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
// map<string, array<string>> fields (groups / collapse).
|
||||
type schemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Enum []interface{} `json:"enum"`
|
||||
Properties map[string]*schemaProperty `json:"properties"`
|
||||
@@ -242,20 +300,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaErrorCollector accumulates validation failures during one full
|
||||
// traversal so the caller can report every problem in a single reply
|
||||
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
|
||||
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
|
||||
// 5000-row --cells array where every cell is malformed — cannot balloon
|
||||
// the error message or the traversal cost: once full, collection
|
||||
// short-circuits everywhere via full().
|
||||
type schemaErrorCollector struct {
|
||||
errs []error
|
||||
}
|
||||
|
||||
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
|
||||
// schemaErrorDisplayLimit entries are rendered; one extra is collected
|
||||
// only to know that truncation happened.
|
||||
const (
|
||||
schemaErrorDisplayLimit = 5
|
||||
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
|
||||
)
|
||||
|
||||
func (c *schemaErrorCollector) add(err error) {
|
||||
if len(c.errs) < collectSchemaErrorsCap {
|
||||
c.errs = append(c.errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
|
||||
|
||||
// validateAgainstSchema recursively checks `value` against `schema`,
|
||||
// prefixing any failure with the JSON path navigated so far.
|
||||
// prefixing any failure with the JSON path navigated so far. It reports
|
||||
// only the first failure — callers that want the full list (the
|
||||
// error-as-teaching aggregate path) use collectSchemaErrors directly.
|
||||
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
||||
if schema == nil {
|
||||
return nil // defensive — current callers always pass &schema, but
|
||||
// keeps validator safe for future programmatic construction.
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, schema, path, c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.errs[0]
|
||||
}
|
||||
|
||||
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
|
||||
// same checks, same messages, same deterministic order, but it keeps
|
||||
// walking after a failure and appends every problem to the collector
|
||||
// (until cap). Two deliberate exceptions to "keep walking":
|
||||
// - a type mismatch stops descent into that node (its children would
|
||||
// produce cascading nonsense against the wrong-typed value);
|
||||
// - oneOf alternatives are probed with throwaway collectors (a failed
|
||||
// alternative is not an error when a later one matches).
|
||||
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
|
||||
if schema == nil || c.full() {
|
||||
return
|
||||
}
|
||||
if value == nil && schema.Nullable {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
|
||||
return // wrong container type — descending would cascade nonsense.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,20 +367,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,20 +398,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.OneOf) > 0 {
|
||||
matched := false
|
||||
for _, sub := range schema.OneOf {
|
||||
if validateAgainstSchema(value, sub, path) == nil {
|
||||
probe := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, sub, path, probe)
|
||||
if len(probe.errs) == 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +422,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// the schema also describes their per-key shape via `properties`.
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
// Inline the missing field's type / one-line description / enum so
|
||||
// the agent supplies a correctly-shaped value on the first retry
|
||||
// instead of fetching the full schema.
|
||||
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
|
||||
msg += "; expected " + hint
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -327,6 +443,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
sub := schema.Properties[key]
|
||||
v, present := obj[key]
|
||||
if !present {
|
||||
@@ -350,14 +469,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(v, sub, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(v, sub, child, c)
|
||||
}
|
||||
}
|
||||
// additionalProperties: enforce only when explicitly declared.
|
||||
// Absent means lenient (matches the file header's stance). Sort
|
||||
// extras so the first rejection is deterministic across runs.
|
||||
// extras so rejection order is deterministic across runs.
|
||||
if schema.AdditionalProperties != nil {
|
||||
extras := make([]string, 0)
|
||||
for key := range obj {
|
||||
@@ -368,17 +485,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
// Inline the node's declared keys (and a did-you-mean when the
|
||||
// unknown key is a near miss) so the agent renames it in one
|
||||
// retry instead of a --print-schema round trip.
|
||||
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
|
||||
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
|
||||
}
|
||||
msg += "; valid properties: " + formatPropertyKeyList(legal)
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
continue
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,33 +516,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if schema.Type == "array" && schema.Items != nil {
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil // type mismatch already reported above.
|
||||
return // type mismatch already reported above.
|
||||
}
|
||||
for i, item := range arr {
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
|
||||
return err
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
collectSchemaErrors(item, schema.Items, child, c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeMismatchError is the type-check branch of validateAgainstSchema
|
||||
// as a typed error, so validateValueAgainstSchema can recognize shape
|
||||
// confusion (vs. deep value errors) and inline a skeleton of the
|
||||
// expected shape. Error() keeps the exact legacy wording.
|
||||
// expected shape. Error() keeps the exact legacy wording; enum /
|
||||
// description ride alongside for the deep-mismatch hintSuffix, so they
|
||||
// never leak into the shallow-skeleton message.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
enum []interface{}
|
||||
description string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// hintSuffix renders the field's description / enum as a one-line tail for
|
||||
// the deep type-mismatch fallback (type is already stated by Error()).
|
||||
// Empty when the field declares neither.
|
||||
func (e *typeMismatchError) hintSuffix() string {
|
||||
var parts []string
|
||||
if d := oneLineDescription(e.description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(e.enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(e.enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// pathDepth counts how many levels below the flag root a JSON path
|
||||
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
||||
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
||||
@@ -605,6 +751,70 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// schemaFieldHint renders a compact one-line "type X, description: …, one of
|
||||
// […]" sketch of a single field's schema, used to enrich a required-missing
|
||||
// error so the agent supplies a correctly-shaped value without --print-schema.
|
||||
// Empty when the field declares none of type / description / enum.
|
||||
func schemaFieldHint(s *schemaProperty) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if s.Type != "" {
|
||||
parts = append(parts, fmt.Sprintf("type %q", s.Type))
|
||||
}
|
||||
if d := oneLineDescription(s.Description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(s.Enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(s.Enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// sortedSchemaPropertyKeys returns the declared property names in a stable
|
||||
// (sorted) order so the valid-property list in a strict unexpected-property
|
||||
// error is deterministic across runs.
|
||||
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// propertyKeyDisplayLimit caps how many declared property names ride inline on
|
||||
// a strict unexpected-property error, so a wide object doesn't bury the actual
|
||||
// error under a wall of keys. Overflow is summarised as "(N more)".
|
||||
const propertyKeyDisplayLimit = 15
|
||||
|
||||
func formatPropertyKeyList(keys []string) string {
|
||||
if len(keys) <= propertyKeyDisplayLimit {
|
||||
return "[" + strings.Join(keys, ", ") + "]"
|
||||
}
|
||||
shown := keys[:propertyKeyDisplayLimit]
|
||||
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
|
||||
}
|
||||
|
||||
// descriptionMaxLen bounds an inlined field description to one reasonable line;
|
||||
// schema descriptions can run several sentences, which would swamp the error.
|
||||
const descriptionMaxLen = 120
|
||||
|
||||
// oneLineDescription collapses a (possibly multi-line) schema description into
|
||||
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
|
||||
// Returns "" for an empty / whitespace-only description.
|
||||
func oneLineDescription(s string) string {
|
||||
collapsed := strings.Join(strings.Fields(s), " ")
|
||||
if collapsed == "" {
|
||||
return ""
|
||||
}
|
||||
if r := []rune(collapsed); len(r) > descriptionMaxLen {
|
||||
return string(r[:descriptionMaxLen]) + "…"
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns the canonical enum entry when the user's
|
||||
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
||||
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -438,6 +440,373 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
|
||||
// additionalProperties:false enhancement: the error lists the node's legal
|
||||
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
|
||||
// the unknown key is a near miss, appends a did-you-mean.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{
|
||||
"background_color":{"type":"string"},
|
||||
"font_weight":{"type":"string"},
|
||||
"font_size":{"type":"integer"}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key under strict schema must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `unexpected property "background_colour"`) {
|
||||
t.Errorf("want the offending key named; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `did you mean "background_color"?`) {
|
||||
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
|
||||
}
|
||||
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
|
||||
t.Errorf("want the valid-property list; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := make([]string, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
|
||||
}
|
||||
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
|
||||
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
|
||||
// required-property-missing error inlines the field's type / one-line
|
||||
// description / enum when the schema describes that field.
|
||||
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["operation"],
|
||||
"properties":{
|
||||
"operation":{
|
||||
"type":"string",
|
||||
"description":"Which mutation to run.",
|
||||
"enum":["insert","delete","move"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required property must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "operation"`,
|
||||
`type "string"`,
|
||||
"description: Which mutation to run.",
|
||||
`one of ["insert", "delete", "move"]`,
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
|
||||
// missing required key with no describing schema keeps the plain legacy
|
||||
// message (no trailing "expected ...").
|
||||
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "; expected") {
|
||||
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
|
||||
// type mismatch (past the skeleton depth limit) still gets no whole-shape
|
||||
// skeleton, but appends the field's enum / description one-liner.
|
||||
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A wrong-typed value three levels deep where the field is an enum string.
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{"type":"array","items":{"type":"object","properties":{
|
||||
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
|
||||
}}}
|
||||
}`)
|
||||
deep := parseValue(t, `[[{"align":42}]]`)
|
||||
err := validateAgainstSchema(deep, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for align must fail")
|
||||
}
|
||||
var tm *typeMismatchError
|
||||
if !errors.As(err, &tm) {
|
||||
t.Fatalf("want *typeMismatchError, got %T", err)
|
||||
}
|
||||
suffix := tm.hintSuffix()
|
||||
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaFieldHint covers the single-field sketch used by
|
||||
// required-missing errors: each of type / description / enum contributes
|
||||
// its own segment, absent parts are simply skipped, and a nil / empty
|
||||
// schema yields no hint at all.
|
||||
func TestSchemaFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
schema *schemaProperty
|
||||
want string
|
||||
}{
|
||||
{"nil schema", nil, ""},
|
||||
{"empty schema", &schemaProperty{}, ""},
|
||||
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
|
||||
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
|
||||
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
|
||||
{
|
||||
"all three",
|
||||
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
|
||||
`type "string", description: 段类型, one of ["text", "link"]`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := schemaFieldHint(tc.schema); got != tc.want {
|
||||
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
|
||||
// at the cap nothing is folded, one past the cap folds into "(1 more)".
|
||||
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := make([]string, 0, propertyKeyDisplayLimit+1)
|
||||
for i := 0; i < propertyKeyDisplayLimit; i++ {
|
||||
keys = append(keys, fmt.Sprintf("k%02d", i))
|
||||
}
|
||||
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
|
||||
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
|
||||
}
|
||||
keys = append(keys, "overflow")
|
||||
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
|
||||
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
|
||||
// neither enum nor description adds no suffix — the deep-mismatch fallback
|
||||
// message must stay byte-identical to the legacy wording in that case.
|
||||
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
|
||||
t.Parallel()
|
||||
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
|
||||
if got := tm.hintSuffix(); got != "" {
|
||||
t.Errorf("no enum/description → empty suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
|
||||
// did-you-mean for a key that differs from a legal one only in casing /
|
||||
// underscore style — a high-frequency LLM slip.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("case-typo key under strict schema must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
|
||||
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
|
||||
// of the doubao case against the real embedded flag-schemas.json: a
|
||||
// rich_text segment without "type" must inline the field's type, enum and
|
||||
// description while keeping the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("rich_text without type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "type" is missing`,
|
||||
`expected type "string"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
|
||||
// numeric rich_text "type" three levels deep gets the field's enum inline
|
||||
// (no whole-shape skeleton), still with the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("numeric rich_text type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`expected type "string", got "number"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "expected shape:") {
|
||||
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
|
||||
// aggregate path: a payload with several independent problems reports them
|
||||
// all in one numbered reply (each with its own teaching hint) instead of
|
||||
// the fail-fast fix-one-retry-hit-the-next loop.
|
||||
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
|
||||
// misses required "type"; cell[0][1].note has the wrong type.
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with two problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"2 validation errors:",
|
||||
`1) required property "type" is missing`,
|
||||
`one of ["text"`, // teaching hint rides along in aggregate mode too
|
||||
`2) [0][1].note: expected type "string"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in aggregated error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
|
||||
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
|
||||
// an explicit truncation tail, never the full flood.
|
||||
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Seven cells all missing required rich_text "type" → 7 independent errors.
|
||||
row := make([]string, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
row = append(row, `{"rich_text":[{"text":"x"}]}`)
|
||||
}
|
||||
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with seven problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "5+ validation errors:") {
|
||||
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "more errors not shown") {
|
||||
t.Errorf("want truncation tail; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "6)") {
|
||||
t.Errorf("must not render entries beyond the display limit; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
|
||||
// alternatives don't leak probe errors into the caller's collector when a
|
||||
// later alternative matches.
|
||||
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(42.0, schema, "", c)
|
||||
if len(c.errs) != 0 {
|
||||
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneLineDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := oneLineDescription(" "); got != "" {
|
||||
t.Errorf("whitespace-only → empty, got %q", got)
|
||||
}
|
||||
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
|
||||
t.Errorf("multi-line collapse = %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
got := oneLineDescription(long)
|
||||
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
|
||||
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
|
||||
@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+styles-put": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
@@ -407,6 +406,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Unambiguous habitual shapes are rewritten onto the wire contract
|
||||
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
|
||||
// so both the standalone cobra path and +batch-update sub-ops (whose
|
||||
// mapFlagView.Str re-encodes composites through here) get the rewrite.
|
||||
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
|
||||
out = norm(out)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
// hooks fill in flat-flag-derived fields) and any flag without an
|
||||
@@ -417,6 +423,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
|
||||
// input shapes onto the wire contract before schema validation — same
|
||||
// contract as enum normalization: only a shape whose meaning is beyond
|
||||
// doubt may be rewritten; anything ambiguous must fail with a prescription
|
||||
// instead. Applied to the parsed JSON value inside parseJSONFlag.
|
||||
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
|
||||
"+cells-set": {"cells": wrapLoneCellObject},
|
||||
"+chart-create": {"properties": normalizeChartHexColors},
|
||||
"+chart-update": {"properties": normalizeChartHexColors},
|
||||
}
|
||||
|
||||
// normalizeChartHexColors walks a chart properties payload and prefixes bare
|
||||
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
|
||||
// Excel-habit form the chart backend rejects with "expected rgba() or
|
||||
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
|
||||
// bare hex color is untouched.
|
||||
func normalizeChartHexColors(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range t {
|
||||
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
|
||||
t[k] = "#" + s
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(val)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range t {
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isColorKey(k string) bool {
|
||||
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
|
||||
}
|
||||
|
||||
func isBareHexColor(s string) bool {
|
||||
if len(s) != 6 && len(s) != 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cellObjectKeys pins the property vocabulary of a single cell in the
|
||||
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
|
||||
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
|
||||
var cellObjectKeys = map[string]struct{}{
|
||||
"border_styles": {},
|
||||
"cell_styles": {},
|
||||
"data_validation": {},
|
||||
"formula": {},
|
||||
"multiple_values": {},
|
||||
"note": {},
|
||||
"rich_text": {},
|
||||
"value": {},
|
||||
}
|
||||
|
||||
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
|
||||
// --cells contract expects. Eval traces show agents writing a single cell
|
||||
// routinely pass {"value":…} without the two array layers; when every key
|
||||
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
|
||||
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
|
||||
// row or a column) is returned untouched for the schema validator to
|
||||
// prescribe.
|
||||
func wrapLoneCellObject(v interface{}) interface{} {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok || len(obj) == 0 {
|
||||
return v
|
||||
}
|
||||
for k := range obj {
|
||||
if _, known := cellObjectKeys[k]; !known {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []interface{}{[]interface{}{obj}}
|
||||
}
|
||||
|
||||
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
|
||||
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -448,146 +540,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
209
shortcuts/sheets/json_flag_normalize_test.go
Normal file
209
shortcuts/sheets/json_flag_normalize_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
|
||||
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
|
||||
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
|
||||
// untouched for the schema validator to prescribe.
|
||||
func TestWrapLoneCellObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wrapped bool
|
||||
}{
|
||||
{"lone value cell", `{"value":"hi"}`, true},
|
||||
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
|
||||
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
|
||||
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
|
||||
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
|
||||
{"empty object stays", `{}`, false},
|
||||
{"scalar stays", `"hi"`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
|
||||
t.Fatalf("bad fixture: %v", err)
|
||||
}
|
||||
out := wrapLoneCellObject(v)
|
||||
_, isWrapped := out.([]interface{})
|
||||
_, wasArray := v.([]interface{})
|
||||
if tc.wrapped && (!isWrapped || wasArray) {
|
||||
t.Errorf("expected wrap to [[cell]], got %#v", out)
|
||||
}
|
||||
if !tc.wrapped && !wasArray && isWrapped {
|
||||
t.Errorf("expected no wrap, got %#v", out)
|
||||
}
|
||||
if tc.wrapped {
|
||||
rows, _ := out.([]interface{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
cells, _ := rows[0].([]interface{})
|
||||
if len(cells) != 1 {
|
||||
t.Fatalf("want 1 cell, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
|
||||
// vocabulary against the embedded +cells-set --cells schema: if the spec
|
||||
// repo adds or removes a cell property, this fails and cellObjectKeys must
|
||||
// be updated (an outdated set only narrows the auto-wrap, but silently
|
||||
// narrowing is still drift).
|
||||
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags["+cells-set"]["cells"]
|
||||
if !ok {
|
||||
t.Fatal("embedded schema for +cells-set --cells missing")
|
||||
}
|
||||
var schema schemaProperty
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
cell := schema.Items
|
||||
if cell != nil && cell.Items != nil {
|
||||
cell = cell.Items
|
||||
}
|
||||
if cell == nil || len(cell.Properties) == 0 {
|
||||
t.Fatal("schema shape changed: expected array→array→object with properties")
|
||||
}
|
||||
for k := range cell.Properties {
|
||||
if _, ok := cellObjectKeys[k]; !ok {
|
||||
t.Errorf("schema property %q missing from cellObjectKeys", k)
|
||||
}
|
||||
}
|
||||
for k := range cellObjectKeys {
|
||||
if _, ok := cell.Properties[k]; !ok {
|
||||
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
|
||||
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
|
||||
// instead of failing "expected type array, got object".
|
||||
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `{"value":"hello"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
|
||||
// wrong JSON kind inlines the expected shape; mangled JSON steers to
|
||||
// stdin/@file.
|
||||
func TestTablePut_SheetsDecodeHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[)`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"stdin", "@./payload.json"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
|
||||
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
|
||||
// pass-through of everything else, including the parseJSONFlag wiring for
|
||||
// the batch sub-op path.
|
||||
func TestNormalizeChartHexColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := map[string]interface{}{
|
||||
"plotArea": map[string]interface{}{
|
||||
"plot": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
|
||||
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
|
||||
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
|
||||
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
normalizeChartHexColors(props)
|
||||
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
|
||||
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
|
||||
t.Errorf("bare hex should gain #, got %v", got)
|
||||
}
|
||||
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
|
||||
t.Errorf("already-prefixed color must not change, got %v", got)
|
||||
}
|
||||
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
|
||||
t.Errorf("rgba color must not change, got %v", got)
|
||||
}
|
||||
last := series[3].(map[string]interface{})
|
||||
if got := last["font_color"]; got != "#ED7D31AA" {
|
||||
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
|
||||
}
|
||||
if got := last["label"]; got != "not a color 4472C4" {
|
||||
t.Errorf("non-color key must not change, got %v", got)
|
||||
}
|
||||
|
||||
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
|
||||
// and picks up the normalizer.
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
|
||||
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
|
||||
})
|
||||
out, err := parseJSONFlag(fv, "properties")
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONFlag: %v", err)
|
||||
}
|
||||
title := out.(map[string]interface{})["title"].(map[string]interface{})
|
||||
if title["font_color"] != "#112233" {
|
||||
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -29,10 +30,14 @@ import (
|
||||
// The tool's contract (post-translation):
|
||||
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
|
||||
//
|
||||
// continue_on_error defaults to false (strict transaction): any failure
|
||||
// rolls back the whole batch. CLI leaves the default in place for the
|
||||
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
|
||||
// only +batch-update lets callers flip it via --continue-on-error.
|
||||
// continue_on_error defaults to false (fail-fast): execution stops at the
|
||||
// first failing sub-op, but sub-ops already applied are NOT rolled back —
|
||||
// the server reports "N succeeded, M failed" and the N stay in the sheet
|
||||
// (verified against live batches; earlier docs wrongly promised a rollback,
|
||||
// which made agents resend whole batches and double-apply the successes).
|
||||
// CLI leaves the default in place for the fan-out shortcuts since they're
|
||||
// idempotent stamps; only +batch-update lets callers flip it via
|
||||
// --continue-on-error.
|
||||
|
||||
// BatchUpdate accepts a CLI-shape operations array (each item
|
||||
// {shortcut, input}); on Validate / DryRun / Execute we translate each
|
||||
@@ -42,7 +47,7 @@ import (
|
||||
var BatchUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-update",
|
||||
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
|
||||
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := batchUpdateInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
|
||||
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
|
||||
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
|
||||
},
|
||||
}
|
||||
@@ -124,6 +137,46 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
|
||||
// requests --inherit-style before at the first row/column, where the
|
||||
// preceding-side style cannot be copied (no preceding row/column exists).
|
||||
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc, _ := op["shortcut"].(string)
|
||||
if sc != "+dim-insert" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
isBefore := false
|
||||
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
|
||||
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
|
||||
isBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isBefore {
|
||||
continue
|
||||
}
|
||||
posRaw, hasPos := input["position"]
|
||||
if !hasPos {
|
||||
continue
|
||||
}
|
||||
// Warn only at the first row/column (idx 0).
|
||||
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
|
||||
// operations list directly) or an envelope object { operations, continue_on_error }
|
||||
// for back-compat with the legacy --data shape. Returns the operations array.
|
||||
@@ -160,6 +213,11 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Tips: []string{
|
||||
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
|
||||
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
|
||||
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
@@ -189,6 +247,10 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Phase-1 deprecation (docs already point at +styles-put): keep the
|
||||
// command working, steer new usage to the superset in-band.
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "modify_sheet_structure" {
|
||||
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
// inherit_style=after copies the following column's style via a plain
|
||||
// before-insert at the same position (the backend anchors on the following
|
||||
// column), so position stays D with side=before.
|
||||
assertInputEquals(t, in, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": "sh1",
|
||||
"operation": "insert",
|
||||
"position": "D",
|
||||
"count": float64(1),
|
||||
"side": "before",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
|
||||
wantMatch: "'input' must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
|
||||
wantMatch: "do not wrap in cell_styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
|
||||
wantMatch: "do not wrap in styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_merges structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
|
||||
wantMatch: "do not wrap in cell_merges",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
|
||||
// wrapped-structure rejection against overreach: the same style fields in
|
||||
// their correct flattened form must translate cleanly — only the wrapper
|
||||
// container keys (cell_styles / styles / cell_merges) are rejected.
|
||||
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_name": "s",
|
||||
"range": "A1",
|
||||
"background_color": "#EBF1F8",
|
||||
"font_weight": "bold",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
|
||||
t.Fatalf("translated style = %#v", style)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
|
||||
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
|
||||
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
|
||||
// If a future dispatch-table addition (e.g. +table-put) carries one of these
|
||||
// flags, its legitimate input would be silently rejected by the wrapper
|
||||
// check — this test turns that silent breakage into a build-time failure.
|
||||
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
wrapped[k] = struct{}{}
|
||||
}
|
||||
for shortcut := range batchOpDispatch {
|
||||
for _, f := range flagsFor(shortcut) {
|
||||
key := strings.ReplaceAll(f.Name, "-", "_")
|
||||
if _, clash := wrapped[key]; clash {
|
||||
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
|
||||
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
|
||||
shortcut, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
|
||||
// several operations are invalid, one reply names them all (numbered, with
|
||||
// each op's own error) instead of failing on the first bad op only. A single
|
||||
// bad op keeps the historical single-error message (no aggregate wrapper).
|
||||
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops reported together", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps plain message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not allowed in +batch-update")
|
||||
if strings.Contains(err.Error(), "operations failed validation") {
|
||||
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
|
||||
// highest-frequency batch failures, so an agent can repair its payload in a
|
||||
// single retry without --help / --print-schema round trips.
|
||||
|
||||
@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
@@ -266,9 +266,13 @@ var ColsResize = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
|
||||
`Different widths per column in one atomic call: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
|
||||
},
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
|
||||
@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,21 +83,28 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
}
|
||||
// Preserve omission so the tool can keep the legacy fallback where
|
||||
// skip_filter inherits skip_hidden. An explicit false must still be sent.
|
||||
if runtime.Changed("skip-filter") {
|
||||
input["skip_filter"] = runtime.Bool("skip-filter")
|
||||
}
|
||||
// --cell-limit was removed from the CLI surface; --max-chars is the single
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's two coarse switches:
|
||||
// tool's switches:
|
||||
//
|
||||
// - include_styles (bool) — toggled by "style" presence
|
||||
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
||||
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
|
||||
// the tool estimate and return per-cell isRowTruncated / isColTruncated
|
||||
//
|
||||
// "value", "comment", and "data_validation" are always returned by the tool
|
||||
// per the schema; they have no dedicated knob today but are accepted in
|
||||
@@ -119,6 +125,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
|
||||
if want["formula"] {
|
||||
input["value_render_option"] = "formula"
|
||||
}
|
||||
if want["truncation"] {
|
||||
input["include_truncation_info"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
|
||||
@@ -139,9 +148,6 @@ var CsvGet = common.Shortcut{
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return sheetsValidationForFlag("range", "--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -165,16 +171,25 @@ var CsvGet = common.Shortcut{
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
|
||||
// requires one, but clips anything past the grid bounds and reports the clip
|
||||
// in actual_range — so an over-wide whole-columns range reads the entire
|
||||
// sheet in one call, with no workbook-info pre-flight. Eval traces show
|
||||
// "read the whole sheet" as a recurring intent (--range was the single most
|
||||
// missed required flag once the rest of the surface was fixed).
|
||||
const csvGetFullSheetRange = "A:ZZZ"
|
||||
|
||||
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
} else {
|
||||
input["range"] = csvGetFullSheetRange
|
||||
}
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
@@ -183,7 +198,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
|
||||
// read cap. Pin max_rows very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["max_rows"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
|
||||
@@ -34,6 +34,41 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get skip filtered rows only",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-filter"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"skip_filter": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get skip hidden but keep filtered rows",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-hidden", "--skip-filter=false"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"skip_hidden": true,
|
||||
"skip_filter": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --include truncation toggles include_truncation_info so the tool
|
||||
// estimates and returns per-cell isRowTruncated / isColTruncated.
|
||||
name: "+cells-get include=truncation",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"include_truncation_info": true,
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Canonical form: --sheet-id + bare --range. Aligned with
|
||||
// +cells-get / +csv-get; before the e2e BUG-019 fix this
|
||||
@@ -74,6 +109,17 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsGet_OmitsSkipFilterWhenUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
|
||||
})
|
||||
got := decodeToolInput(t, body, "get_cell_ranges")
|
||||
if _, ok := got["skip_filter"]; ok {
|
||||
t.Fatalf("skip_filter must be omitted when --skip-filter is unset: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style
|
||||
// selector contract: at least one of --sheet-id / --sheet-name must be
|
||||
// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1"
|
||||
@@ -92,7 +138,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
|
||||
// TestReadData_RequiresRange covers the trim-based --range guard on the
|
||||
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
|
||||
// must still be rejected by Validate).
|
||||
// must still be rejected by Validate). +csv-get is deliberately absent:
|
||||
// its --range is optional — omitted/blank means a whole-sheet read (see
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
|
||||
func TestReadData_RequiresRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -100,7 +148,6 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
sc common.Shortcut
|
||||
}{
|
||||
{"+cells-get", CellsGet},
|
||||
{"+csv-get", CsvGet},
|
||||
{"+dropdown-get", DropdownGet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -114,6 +161,23 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
|
||||
// with --range omitted the request carries the over-wide clip range, so a
|
||||
// full read needs no workbook-info pre-flight (eval: --range was the most
|
||||
// missed required flag on +csv-get once the rest of the surface settled).
|
||||
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, csvGetFullSheetRange) {
|
||||
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
|
||||
// directly (white-box).
|
||||
func TestInfoTypeFromInclude(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,12 +129,20 @@ var DimInsert = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
|
||||
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
|
||||
},
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -148,6 +157,9 @@ var DimInsert = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,8 +169,31 @@ var DimInsert = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
|
||||
// be copied: --inherit-style before at the first row/column, where no
|
||||
// preceding row/column exists. The row/column is still inserted before
|
||||
// --position, just without style inheritance. (--inherit-style after has no
|
||||
// such edge — a plain before-insert always has a following row/column.)
|
||||
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
|
||||
|
||||
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
|
||||
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
|
||||
return false
|
||||
}
|
||||
// Only the first row/column (idx 0) has no preceding row/column.
|
||||
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
|
||||
return err == nil && idx == 0
|
||||
}
|
||||
|
||||
// dimInsertInput passes --position (1-based row number "3" or column letter
|
||||
// "C") straight to the tool's `position` field; --count maps to `count`.
|
||||
// "C") to the tool's `position` field; --count maps to `count`.
|
||||
//
|
||||
// +dim-insert's public contract is always "insert before --position";
|
||||
// --inherit-style only selects which side's style the new row/column copies,
|
||||
// never the insertion side. The sheet-ai tool always copies the *anchor*
|
||||
// column's style (the target passed as position), regardless of side — so
|
||||
// --inherit-style before is emulated by anchoring one unit earlier. See the
|
||||
// switch below.
|
||||
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -184,11 +219,27 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
"count": count,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
// --inherit-style selects which side's style the blank row/column copies;
|
||||
// the insertion always lands *before* --position. Empirically the addCol
|
||||
// backend copies the *anchor* column's style (the target passed as
|
||||
// position), regardless of side — side only decides whether the blank lands
|
||||
// before or after that anchor (verified live, see
|
||||
// TestDimInsertInheritStyleSideMapping):
|
||||
// after → side=before at P: the blank lands at P and anchor P becomes the
|
||||
// *following* neighbour, so the blank copies it. Position unchanged.
|
||||
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
|
||||
// == insert-before-P) and anchor P-1 becomes the *preceding*
|
||||
// neighbour, so the blank copies it.
|
||||
switch runtime.Str("inherit-style") {
|
||||
case "before":
|
||||
input["side"] = "before"
|
||||
case "after":
|
||||
input["side"] = "after"
|
||||
input["side"] = "before"
|
||||
case "before":
|
||||
if prev, ok := a1PositionBefore(position); ok {
|
||||
input["side"] = "after"
|
||||
input["position"] = prev
|
||||
}
|
||||
// First row/column: no preceding row/column exists, so fall back to a
|
||||
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -203,10 +254,34 @@ var DimDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-delete"),
|
||||
Validate: validateDimRangeOp("delete"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("ranges") {
|
||||
if runtime.Changed("range") {
|
||||
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
|
||||
}
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
}
|
||||
return validateDimRangeOp("delete")(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
if runtime.Changed("ranges") {
|
||||
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
@@ -219,6 +294,21 @@ var DimDelete = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("ranges") {
|
||||
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,9 +322,76 @@ var DimDelete = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"Row/column deletion is irreversible. Always preview with --dry-run first.",
|
||||
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one atomic call — the CLI orders positions descending, so indexes never shift under you.`,
|
||||
},
|
||||
}
|
||||
|
||||
// dimDeleteRangesOps parses --ranges into one atomic batch of
|
||||
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
|
||||
// deleting an earlier row shifts every later index up, so ascending
|
||||
// execution deletes the wrong rows — the recurring failure of hand-built
|
||||
// dim-delete batches in eval traces. Same-dimension and non-overlap are
|
||||
// enforced up front.
|
||||
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "ranges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
|
||||
}
|
||||
if len(raw) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
|
||||
}
|
||||
type span struct {
|
||||
raw string
|
||||
start, end int
|
||||
}
|
||||
spans := make([]span, 0, len(raw))
|
||||
dimension := ""
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
|
||||
}
|
||||
dim, start, end, err := parseA1Range(s)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
|
||||
}
|
||||
if dimension == "" {
|
||||
dimension = dim
|
||||
} else if dim != dimension {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
|
||||
}
|
||||
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
|
||||
for i := 1; i < len(spans); i++ {
|
||||
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
|
||||
// (or duplicate) makes the later delete hit already-shifted positions.
|
||||
if spans[i].end >= spans[i-1].start {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
|
||||
}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(spans))
|
||||
for _, sp := range spans {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "delete",
|
||||
"range": sp.raw,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "modify_sheet_structure",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// validateDimRangeOp returns a Validate closure that delegates to
|
||||
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
|
||||
// extra `op` argument. Token check happens here; the rest is the builder.
|
||||
@@ -292,7 +449,10 @@ var DimFreeze = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
|
||||
},
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -557,6 +717,23 @@ func columnIndexToLetter(idx int) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
|
||||
// "C" → "B"), preserving row/column form. ok is false when s is the first
|
||||
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
|
||||
// position. Callers validate via parseA1Position first, so in practice ok is
|
||||
// false only at the first row/column.
|
||||
func a1PositionBefore(s string) (pos string, ok bool) {
|
||||
dimension, idx, err := parseA1Position(s)
|
||||
if err != nil || idx == 0 {
|
||||
return "", false
|
||||
}
|
||||
if dimension == "row" {
|
||||
// idx is 0-based; the 1-based number one row earlier is idx itself.
|
||||
return strconv.Itoa(idx), true
|
||||
}
|
||||
return columnIndexToLetter(idx - 1), true
|
||||
}
|
||||
|
||||
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
|
||||
//
|
||||
// Moves a contiguous block of rows or columns to a new index in the same
|
||||
|
||||
@@ -48,6 +48,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// --inherit-style before copies the preceding row: anchor row 5 and
|
||||
// insert after it (side=after), so the blank still lands before row 6.
|
||||
name: "+dim-insert row position=6 count=3 inherit-before",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
|
||||
@@ -56,9 +58,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "6",
|
||||
"position": "5",
|
||||
"count": float64(3),
|
||||
"side": "before",
|
||||
"side": "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,6 +171,93 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
position string
|
||||
inherit string
|
||||
wantPosition string
|
||||
wantSide string
|
||||
wantSideSet bool
|
||||
}{
|
||||
{
|
||||
name: "after copies the following style with a plain before-insert, position unchanged",
|
||||
position: "D",
|
||||
inherit: "after",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before anchors one column earlier (side=after) to copy the preceding style",
|
||||
position: "D",
|
||||
inherit: "before",
|
||||
wantPosition: "C",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before on a row anchors one row earlier",
|
||||
position: "6",
|
||||
inherit: "before",
|
||||
wantPosition: "5",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before at the first column falls back to a plain before-insert",
|
||||
position: "A",
|
||||
inherit: "before",
|
||||
wantPosition: "A",
|
||||
wantSideSet: false,
|
||||
},
|
||||
{
|
||||
name: "after at the first column still works (before-insert anchors the following)",
|
||||
position: "A",
|
||||
inherit: "after",
|
||||
wantPosition: "A",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "default (flag omitted) omits side, backend inherits the following row/column",
|
||||
position: "D",
|
||||
wantPosition: "D",
|
||||
wantSideSet: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
|
||||
if tc.inherit != "" {
|
||||
args = append(args, "--inherit-style", tc.inherit)
|
||||
}
|
||||
body := parseDryRunBody(t, DimInsert, args)
|
||||
got := decodeToolInput(t, body, "modify_sheet_structure")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": tc.wantPosition,
|
||||
"count": float64(1),
|
||||
})
|
||||
|
||||
gv, ok := got["side"]
|
||||
if ok != tc.wantSideSet {
|
||||
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
|
||||
}
|
||||
if ok && gv != tc.wantSide {
|
||||
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimRange_Validation covers the A1 range parser's edge cases routed
|
||||
// through +dim-hide (any --range shortcut works; we just need to exercise
|
||||
// the validator).
|
||||
|
||||
272
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
272
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +styles-put ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Declarative visual spec for EXISTING spreadsheets. Eval attribution
|
||||
// showed ~73% of real +batch-update calls were pure formatting finishers
|
||||
// (style stamps + merges + resizes + freeze) hand-assembled as imperative
|
||||
// operations arrays — the top error surface. +styles-put replaces that
|
||||
// with the {styles:[...]} protocol already shared by +workbook-create /
|
||||
// +table-put --styles (identical vocabulary, parsed by the same
|
||||
// parseWorkbookCreateStyleItem), applied to a live workbook and expanded
|
||||
// client-side into ONE atomic batch_update.
|
||||
//
|
||||
// Per-sheet expansion order (server behavior verified live: style stamps
|
||||
// over merged regions are allowed — the top-left-only restriction applies
|
||||
// to value writes, not styles):
|
||||
//
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze
|
||||
var StylesPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+styles-put",
|
||||
Description: "Apply one declarative visual spec (styles/merges/row-col sizes/freeze) to existing sheets in one atomic batch.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+styles-put"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +styles-put --url <URL> --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:F1","font_weight":"bold"}],"freeze":{"rows":1}}]}'`,
|
||||
"Same --styles vocabulary as +workbook-create / +table-put; one item per target sheet, name = the real sheet name.",
|
||||
"Style stamps are safe to re-run; the whole spec goes out as one atomic batch.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stylesPutOperations(runtime, token)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
ops, _ := stylesPutOperations(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := stylesPutOperations(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// stylesPutOperations parses --styles ({styles:[...]}, one item per target
|
||||
// sheet) and expands it into the MCP batch_update operations array. Reuses
|
||||
// the shared workbook-create style item parser, so field validation, alias
|
||||
// normalization (border "all" shorthand, style vocabulary) and the
|
||||
// aggregate-all-issues error shape are identical across the three --styles
|
||||
// carriers.
|
||||
func stylesPutOperations(runtime flagView, token string) ([]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles is required")
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles.styles must be a non-empty array (one item per target sheet)")
|
||||
}
|
||||
var probs []error
|
||||
type sheetSpec struct {
|
||||
name string
|
||||
payload *workbookCreateStylePayload
|
||||
}
|
||||
specs := make([]sheetSpec, 0, len(items))
|
||||
seenName := map[string]bool{}
|
||||
for i, item := range items {
|
||||
path := fmt.Sprintf("--styles.styles[%d]", i)
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name is required (the real sheet name; check +workbook-info)", path))
|
||||
continue
|
||||
}
|
||||
if seenName[name] {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name %q appears twice; merge the two items", path, name))
|
||||
continue
|
||||
}
|
||||
seenName[name] = true
|
||||
payload, itemProbs := parseWorkbookCreateStyleItem(item, path)
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
specs = append(specs, sheetSpec{name: name, payload: payload})
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops := make([]interface{}, 0, len(specs)*4)
|
||||
var totalCells int64
|
||||
appendVisual := func(name string, op workbookCreateStyleOp) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, "", name, op)
|
||||
if toolName == "" {
|
||||
return
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{"tool_name": toolName, "input": input})
|
||||
}
|
||||
for _, spec := range specs {
|
||||
// merges first so subsequent style stamps see the final grid.
|
||||
for _, m := range spec.payload.CellMerges {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "cell_merge", Range: m.Range, MergeType: m.MergeType})
|
||||
}
|
||||
for _, cs := range coalesceStyleStamps(spec.payload.CellStyles) {
|
||||
rows, cols, err := rangeDimensions(cs.Range)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("styles", "cell_styles range %q: %v", cs.Range, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("styles", cs.Range, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": spec.name,
|
||||
"range": stripSheetPrefix(cs.Range),
|
||||
"cells": fillCellsMatrix(rows, cols, cs.Style),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, rs := range spec.payload.RowSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "row_size", Range: rs.Range, ResizeType: rs.ResizeType, Size: rs.Size})
|
||||
}
|
||||
for _, csz := range spec.payload.ColSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "col_size", Range: csz.Range, ResizeType: csz.ResizeType, Size: csz.Size})
|
||||
}
|
||||
if f := spec.payload.Freeze; f != nil {
|
||||
if f.Rows > 0 {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_rows", Size: f.Rows})
|
||||
}
|
||||
if f.Cols > 0 {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_cols", Size: f.Cols})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ops) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("styles",
|
||||
"--styles expands to %d operations even after merging adjacent same-style ranges, over the %d cap; split the spec into several +styles-put calls — and for alternating-row banding or value-dependent coloring use +cond-format-create instead of per-row stamps",
|
||||
len(ops), maxBatchOperations)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// coalesceStyleStamps merges cell_styles entries that carry the IDENTICAL
|
||||
// style into larger rectangles: same column span + contiguous/overlapping
|
||||
// rows fuse vertically, same row span + contiguous columns fuse
|
||||
// horizontally, iterated to a fixpoint. Models routinely emit one entry per
|
||||
// row (07-21 rerun: specs expanding to 184/203/861 operations against the
|
||||
// 100-op cap); a declarative spec describes intent, so execution shape is
|
||||
// the CLI's to optimize. Entries with unparsable ranges pass through
|
||||
// untouched (the per-op validation reports them with proper context).
|
||||
func coalesceStyleStamps(ops []workbookCreateCellStyleOp) []workbookCreateCellStyleOp {
|
||||
if len(ops) < 2 {
|
||||
return ops
|
||||
}
|
||||
type rect struct{ c1, r1, c2, r2 int }
|
||||
type group struct {
|
||||
style map[string]interface{}
|
||||
rects []rect
|
||||
}
|
||||
var order []string
|
||||
groups := map[string]*group{}
|
||||
out := make([]workbookCreateCellStyleOp, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
key, jerr := json.Marshal(op.Style) // map keys marshal sorted → canonical
|
||||
if err != nil || jerr != nil {
|
||||
out = append(out, op)
|
||||
continue
|
||||
}
|
||||
g, ok := groups[string(key)]
|
||||
if !ok {
|
||||
g = &group{style: op.Style}
|
||||
groups[string(key)] = g
|
||||
order = append(order, string(key))
|
||||
}
|
||||
g.rects = append(g.rects, rect{c1, r1, c2, r2})
|
||||
}
|
||||
for _, key := range order {
|
||||
g := groups[key]
|
||||
rects := g.rects
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for i := 0; i < len(rects) && !changed; i++ {
|
||||
for j := i + 1; j < len(rects); j++ {
|
||||
a, b := rects[i], rects[j]
|
||||
var merged rect
|
||||
switch {
|
||||
case a.c1 == b.c1 && a.c2 == b.c2 && b.r1 <= a.r2+1 && a.r1 <= b.r2+1:
|
||||
merged = rect{a.c1, min(a.r1, b.r1), a.c2, max(a.r2, b.r2)}
|
||||
case a.r1 == b.r1 && a.r2 == b.r2 && b.c1 <= a.c2+1 && a.c1 <= b.c2+1:
|
||||
merged = rect{min(a.c1, b.c1), a.r1, max(a.c2, b.c2), a.r2}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
rects[i] = merged
|
||||
rects = append(rects[:j], rects[j+1:]...)
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rc := range rects {
|
||||
out = append(out, workbookCreateCellStyleOp{
|
||||
Range: fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(rc.c1), rc.r1+1,
|
||||
columnIndexToLetter(rc.c2), rc.r2+1),
|
||||
Style: g.style,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripSheetPrefix drops an optional "Sheet!"-style prefix from an A1 range:
|
||||
// the target sheet is already carried by the spec item's name, and the
|
||||
// batch sub-op input names the sheet separately.
|
||||
func stripSheetPrefix(rangeStr string) string {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
return strings.TrimSpace(rangeStr[idx+1:])
|
||||
}
|
||||
return strings.TrimSpace(rangeStr)
|
||||
}
|
||||
347
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
347
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stylesPutView(spec map[string]interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
|
||||
// batch_update operations array (server-side order dependence verified live).
|
||||
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
|
||||
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure", "modify_sheet_structure"}
|
||||
if len(ops) != len(wantTools) {
|
||||
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
|
||||
}
|
||||
for i, want := range wantTools {
|
||||
op := ops[i].(map[string]interface{})
|
||||
if op["tool_name"] != want {
|
||||
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
|
||||
}
|
||||
input := op["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "Sheet1" {
|
||||
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
|
||||
}
|
||||
if input["excel_id"] != testToken {
|
||||
t.Fatalf("ops[%d] missing excel_id", i)
|
||||
}
|
||||
}
|
||||
// The style stamp carries a cells matrix matching the range (1×2).
|
||||
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := stamp["cells"].([][]interface{})
|
||||
if len(cells) != 1 || len(cells[0]) != 2 {
|
||||
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
|
||||
}
|
||||
// Freeze ops carry the freeze counts.
|
||||
fr := ops[4].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if fr["operation"] != "freeze" || fr["freeze_rows"] != 1 {
|
||||
t.Fatalf("freeze rows op = %v", fr)
|
||||
}
|
||||
fc := ops[5].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if fc["freeze_columns"] != 2 {
|
||||
t.Fatalf("freeze cols op = %v", fc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_Validation pins the aggregate error shape and the
|
||||
// section/name requirements.
|
||||
func TestStylesPutOperations_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing name and empty item aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
|
||||
map[string]interface{}{"name": "S2"},
|
||||
},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "name is required")
|
||||
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
|
||||
t.Fatalf("message %q missing empty-item issue", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate sheet name rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
|
||||
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{item, item2},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "appears twice")
|
||||
})
|
||||
|
||||
t.Run("freeze-only item is valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-zero freeze rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "at least one dimension")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
|
||||
// payload path (--styles cell_styles objects) accepts the same habitual
|
||||
// vocabulary the flag path already normalized — border family folding, wrap
|
||||
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
|
||||
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
|
||||
item := map[string]interface{}{"range": "A1:B1"}
|
||||
for k, v := range styleFields {
|
||||
item[k] = v
|
||||
}
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell_styles = %v, want middle/bold", cs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
|
||||
requireValidation(t, err, `did you mean "bottom"`)
|
||||
})
|
||||
|
||||
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"wrap_text": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["word_wrap"] != "auto-wrap" {
|
||||
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("borders object folds into border_styles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top == nil || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles = %v, want all-sides solid", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"border_bottom": map[string]interface{}{"style": "solid"},
|
||||
"border_top_color": "#FF0000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
bottom, _ := bs["bottom"].(map[string]interface{})
|
||||
topSide, _ := bs["top"].(map[string]interface{})
|
||||
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
|
||||
t.Fatalf("border_styles = %v", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("border_style thin means thin solid line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top["weight"] != "thin" || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles.top = %v, want thin solid", top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
|
||||
requireValidation(t, err, "fore_color is ambiguous")
|
||||
})
|
||||
|
||||
t.Run("bare string cell_merges accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_merges": []interface{}{"A5:B6"},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
|
||||
t.Fatalf("merge op = %v", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
|
||||
// the shared styles resize parser: height in row_sizes / width in col_sizes
|
||||
// resolve to size silently; the wrong dimension's word is a targeted error.
|
||||
func TestStylesResizeSizeAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block := input["resize_height"].(map[string]interface{})
|
||||
if block["value"] != 36 {
|
||||
t.Fatalf("resize_height = %v, want value 36", block)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "does not apply to this array")
|
||||
})
|
||||
|
||||
t.Run("size plus alias together rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "either size or height")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimDeleteRangesOps pins the descending-order expansion and the
|
||||
// same-dimension / non-overlap guards.
|
||||
func TestDimDeleteRangesOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := func(ranges ...interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
|
||||
}
|
||||
|
||||
t.Run("rows execute descending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got []string
|
||||
for _, op := range ops {
|
||||
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
|
||||
}
|
||||
want := []string{"11:13", "8:8", "5:5"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed dimensions rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
|
||||
requireValidation(t, err, "rows OR columns")
|
||||
})
|
||||
|
||||
t.Run("overlap rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
|
||||
requireValidation(t, err, "overlap")
|
||||
})
|
||||
|
||||
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"5:5", "8:8"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
|
||||
return tablePutWrite(ctx, runtime, token, payload, styles)
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||||
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
|
||||
// error, so the retry needs no --print-schema round trip. Field vocabulary
|
||||
// mirrors tableSheetIn.
|
||||
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
|
||||
|
||||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||||
@@ -259,7 +265,19 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
// Eval traces show two distinct decode failures that each burned
|
||||
// retries: a field with the wrong JSON kind (columns as objects,
|
||||
// dtypes as an array) — fixed by seeing the expected shape once —
|
||||
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
|
||||
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
var ute *json.UnmarshalTypeError
|
||||
if errors.As(err, &ute) {
|
||||
return nil, verr.WithHint(
|
||||
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
|
||||
tablePutSheetsSkeleton)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
@@ -1208,12 +1226,11 @@ var TableGet = common.Shortcut{
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"N/A\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1354,11 +1371,18 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
"value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// --max-chars binds the char budget (default 500000); --output-path lifts it
|
||||
// to unbounded. Without this the tool applied its own ~50000 default and
|
||||
// silently dropped rows past it with no signal in the +table-get output.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input, t.id, t.name)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := cellRangesTruncated(out)
|
||||
grid := extractCellGrid(out)
|
||||
if len(grid) == 0 {
|
||||
return emptySpec(), nil
|
||||
@@ -1433,9 +1457,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if len(formats) > 0 {
|
||||
spec["formats"] = formats
|
||||
}
|
||||
// The tool clipped the read at max_chars: rows past the cap are missing from
|
||||
// data. Surface it so the caller doesn't mistake a partial read for the whole
|
||||
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the whole sheet in one lossless pass (no cap). Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
|
||||
// max_chars — either the top-level has_more flag or the first range's truncated
|
||||
// flag. Used by +table-get, whose spec output otherwise drops both signals.
|
||||
func cellRangesTruncated(out interface{}) bool {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if hm, ok := m["has_more"].(bool); ok && hm {
|
||||
return true
|
||||
}
|
||||
ranges, _ := m["ranges"].([]interface{})
|
||||
if len(ranges) > 0 {
|
||||
if r0, ok := ranges[0].(map[string]interface{}); ok {
|
||||
if t, ok := r0["truncated"].(bool); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
|
||||
// or "" for an empty sheet.
|
||||
//
|
||||
@@ -1522,7 +1575,7 @@ func readCellFormat(cell map[string]interface{}) string {
|
||||
// inferColumnType decides a column's type from its data cells: a date
|
||||
// number_format guides each cell's type, but a column is given a non-string type
|
||||
// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a
|
||||
// number column with a stray "暂无", a date column with a bare count); declaring
|
||||
// number column with a stray "N/A", a date column with a bare count); declaring
|
||||
// number/date while a string value rides along makes the output inconsistent —
|
||||
// it breaks round-trip back into +table-put (which rejects a string in a number
|
||||
// column) and crashes pandas astype. So a mixed column degrades to string
|
||||
|
||||
@@ -1140,7 +1140,7 @@ func TestTableGet_InferColumnType(t *testing.T) {
|
||||
// Mixed number+text degrades to string (self-consistent: every value is then
|
||||
// a string), so the column round-trips and pandas doesn't choke. Numeric
|
||||
// coercion of the dirty cells is left to the caller (pandas to_numeric).
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("N/A", ""), mk(200.0, "")), 0); typ != "string" {
|
||||
t.Errorf("mixed number+text col → %s, want string", typ)
|
||||
}
|
||||
// A bare number mixed into a date column must NOT stay date (would serial-
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/drive"
|
||||
@@ -405,7 +406,11 @@ var SheetCopy = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-copy"),
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
|
||||
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
|
||||
},
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -914,6 +919,14 @@ type workbookCreateStylePayload struct {
|
||||
RowSizes []workbookCreateResizeOp
|
||||
ColSizes []workbookCreateResizeOp
|
||||
CellMerges []workbookCreateMergeOp
|
||||
Freeze *workbookCreateFreezeOp
|
||||
}
|
||||
|
||||
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
|
||||
// Zero means "leave that dimension alone".
|
||||
type workbookCreateFreezeOp struct {
|
||||
Rows int
|
||||
Cols int
|
||||
}
|
||||
|
||||
type workbookCreateCellStyleOp struct {
|
||||
@@ -965,7 +978,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
|
||||
if len(items) != 1 {
|
||||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
@@ -988,21 +1005,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
|
||||
}
|
||||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||||
var probs []error
|
||||
for i, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||||
continue
|
||||
}
|
||||
if name != payload.Sheets[i].Name {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
|
||||
continue
|
||||
}
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1030,182 +1054,337 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
|
||||
// are validated even after one fails, and every issue is returned in the
|
||||
// slice: eval traces show agents fixing --styles errors one round trip per
|
||||
// error (border side, then row_sizes.type, then size…) because only the
|
||||
// first was ever reported.
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var err error
|
||||
var probs []error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
var errsHere []error
|
||||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["freeze"]; ok {
|
||||
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
} else {
|
||||
payload.Freeze = freeze
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
if len(probs) > 0 {
|
||||
return nil, probs
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
|
||||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
|
||||
// one dimension must be positive — an all-zero freeze is a no-op the caller
|
||||
// almost certainly didn't mean.
|
||||
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
|
||||
obj, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
|
||||
}
|
||||
out := &workbookCreateFreezeOp{}
|
||||
for k, v := range obj {
|
||||
n, isNum := v.(float64)
|
||||
if !isNum || n != float64(int(n)) || n < 0 {
|
||||
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
|
||||
}
|
||||
switch k {
|
||||
case "rows":
|
||||
out.Rows = int(n)
|
||||
case "cols", "columns":
|
||||
out.Cols = int(n)
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
|
||||
}
|
||||
}
|
||||
if out.Rows == 0 && out.Cols == 0 {
|
||||
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinStyleValidationErrors folds the issues collected across one --styles
|
||||
// parse into a single typed error that lists them all, so the caller can fix
|
||||
// the whole payload in one retry instead of one error per round trip.
|
||||
func joinStyleValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return probs[0]
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
if p, ok := errs.ProblemOf(e); ok {
|
||||
msgs = append(msgs, p.Message)
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, e.Error())
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return common.ValidationErrorf("--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
|
||||
}
|
||||
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
|
||||
}
|
||||
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
|
||||
}
|
||||
mergeType = strings.TrimSpace(v)
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||||
// A bare range string means {range: s, merge_type: all} — the only
|
||||
// possible reading (07-20 eval hit).
|
||||
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
||||
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
|
||||
}
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
|
||||
}
|
||||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
|
||||
}
|
||||
|
||||
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
|
||||
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
|
||||
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
|
||||
// the caller's enum check to reject.
|
||||
func normalizeMergeType(v string) string {
|
||||
lower := strings.ToLower(v)
|
||||
lower = strings.TrimPrefix(lower, "merge_")
|
||||
switch lower {
|
||||
case "all", "rows", "columns":
|
||||
return lower
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
// resizeOpExample renders a complete valid op for the dimension, inlined on
|
||||
// every type/size error: eval traces show the field errors chaining (type
|
||||
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
|
||||
// trip, because no error ever showed a whole valid op at once.
|
||||
func resizeOpExample(dimension string) string {
|
||||
if dimension == "column" {
|
||||
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
|
||||
}
|
||||
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType != "" {
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
n, ok := util.ToFloat64(raw)
|
||||
if !ok || n <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
|
||||
}
|
||||
return ops, nil
|
||||
// size is the canonical dimension key (uniform across row_sizes and
|
||||
// col_sizes — the array name already carries the dimension). The Excel-
|
||||
// vocabulary alias (height on rows, width on columns) is accepted
|
||||
// silently; the WRONG dimension's word is a targeted error, never a
|
||||
// silent rewrite.
|
||||
alias, wrongDim := "height", "width"
|
||||
if dimension == "column" {
|
||||
alias, wrongDim = "width", "height"
|
||||
}
|
||||
if _, has := op[wrongDim]; has {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
|
||||
}
|
||||
sizeRaw, hasSize := op["size"]
|
||||
if aliasRaw, hasAlias := op[alias]; hasAlias {
|
||||
if hasSize {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
|
||||
}
|
||||
sizeRaw, hasSize = aliasRaw, true
|
||||
}
|
||||
size := 0
|
||||
if hasSize {
|
||||
n, ok := util.ToFloat64(sizeRaw)
|
||||
if !ok || n <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
// type is optional ceremony when a pixel size is given: {range, size}
|
||||
// means a pixel resize, exactly as --width/--height without --type does
|
||||
// on the flag path. Explicit standard/auto still needs type.
|
||||
if resizeType == "" {
|
||||
if size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
resizeType = "pixel"
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
@@ -1245,6 +1424,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := foldBorderFamilyAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeCellStyleAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1259,15 +1441,26 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["border_styles"] = m
|
||||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||||
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
|
||||
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
|
||||
default:
|
||||
if !workbookCreateCellStyleField(k) {
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
|
||||
// Universal rejection with did-you-mean + the full field list:
|
||||
// this is the mechanism that absorbs the infinite tail of
|
||||
// spelling permutations at a fixed one-retry cost — silent
|
||||
// aliases are reserved for high-frequency words from real
|
||||
// external vocabularies (see the style_vocab.go contract).
|
||||
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
|
||||
if match := suggest.Closest(strings.ToLower(k), workbookCreateCellStyleFieldList, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
|
||||
return nil, common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
cellStyle[k] = v
|
||||
}
|
||||
@@ -1278,6 +1471,14 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workbookCreateCellStyleFieldList is the canonical style vocabulary plus the
|
||||
// two border carriers, in display order for the unknown-field hint.
|
||||
var workbookCreateCellStyleFieldList = []string{
|
||||
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap", "border", "border_styles",
|
||||
}
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
@@ -1299,7 +1500,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||||
switch side {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -1516,7 +1717,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
|
||||
for _, op := range styles.CellMerges {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||||
}
|
||||
@@ -1526,6 +1727,14 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
for _, op := range styles.ColSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
if styles.Freeze != nil {
|
||||
if styles.Freeze.Rows > 0 {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_rows", Size: styles.Freeze.Rows})
|
||||
}
|
||||
if styles.Freeze.Cols > 0 {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_cols", Size: styles.Freeze.Cols})
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -1564,6 +1773,18 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
|
||||
input["resize_width"] = block
|
||||
}
|
||||
return input, "resize_range"
|
||||
case "freeze_rows", "freeze_cols":
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "freeze",
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op.Kind == "freeze_rows" {
|
||||
input["freeze_rows"] = op.Size
|
||||
} else {
|
||||
input["freeze_columns"] = op.Size
|
||||
}
|
||||
return input, "modify_sheet_structure"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ import (
|
||||
|
||||
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
|
||||
// (JSON), with an optional --copy-to-range to replicate the written block
|
||||
// across a larger area (formula refs auto-shift).
|
||||
// across a larger area (formula refs auto-shift). The plural form --writes
|
||||
// ([{sheet_name, range, cells}, …]) fans scattered regions — cross-sheet
|
||||
// allowed — into ONE atomic batch_update: eval traces show "fix all broken
|
||||
// formulas across ranges/sheets" as the dominant homogeneous scenario still
|
||||
// hand-assembled as +batch-update operations arrays.
|
||||
var CellsSet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set",
|
||||
@@ -48,9 +52,31 @@ var CellsSet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set"),
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
|
||||
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
|
||||
`Scattered regions (e.g. fixing formulas across ranges/sheets): --writes '[{"sheet_name":…,"range":…,"cells":[[…]]}, …]' — one atomic call, sheet selector inside each item.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("writes") {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cellsSetWritesOps(runtime, token)
|
||||
return err
|
||||
}
|
||||
return validateViaInput(cellsSetInput)(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
if runtime.Changed("writes") {
|
||||
ops, _ := cellsSetWritesOps(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
@@ -60,6 +86,21 @@ var CellsSet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("writes") {
|
||||
ops, err := cellsSetWritesOps(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,6 +118,108 @@ var CellsSet = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// cellsSetWritesOps parses --writes ([{sheet_name|sheet_id, range, cells}, …])
|
||||
// and expands it into set_cell_range operations for ONE atomic batch_update.
|
||||
// Single source of truth per item: the sheet selector LIVES IN THE ITEM (same
|
||||
// convention as +batch-update sub-ops and +styles-put items — no top-level
|
||||
// fallback, no precedence table to remember). Every item runs through the
|
||||
// exact standalone pipeline (key vocabulary, style acceptance layer, matrix
|
||||
// precheck, schema validation) via a per-item flag view, and item errors are
|
||||
// aggregated so one retry fixes them all.
|
||||
func cellsSetWritesOps(runtime *common.RuntimeContext, token string) ([]interface{}, error) {
|
||||
for _, conflicting := range []string{"range", "cells", "copy-to-range"} {
|
||||
if runtime.Changed(conflicting) {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes and --%s are mutually exclusive: single region → --range + --cells; multiple regions → --writes alone", conflicting)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("sheet-name")) != "" || strings.TrimSpace(runtime.Str("sheet-id")) != "" {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes does not accept a top-level sheet selector — put sheet_name (or sheet_id) inside each writes item, same as +batch-update sub-ops")
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "writes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes must be a non-empty JSON array of {sheet_name, range, cells} items")
|
||||
}
|
||||
if len(raw) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes accepts at most %d items; got %d — merge adjacent regions or split into several calls", maxBatchOperations, len(raw))
|
||||
}
|
||||
topLevelOverwrite := runtime.Bool("allow-overwrite")
|
||||
ops := make([]interface{}, 0, len(raw))
|
||||
var probs []error
|
||||
var totalCells int64
|
||||
for i, v := range raw {
|
||||
item, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d] must be an object like {\"sheet_name\":…,\"range\":…,\"cells\":[[…]]}", i))
|
||||
continue
|
||||
}
|
||||
if err := normalizeSubOpInputKeys("+cells-set", item); err != nil {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
if topLevelOverwrite {
|
||||
if _, has := item["allow_overwrite"]; !has {
|
||||
item["allow_overwrite"] = true
|
||||
}
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+cells-set", item)
|
||||
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
|
||||
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
|
||||
input, err := cellsSetInput(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
if cells, ok := input["cells"].([]interface{}); ok {
|
||||
for _, row := range cells {
|
||||
if r, ok := row.([]interface{}); ok {
|
||||
totalCells += int64(len(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
if err := joinWritesValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// joinWritesValidationErrors mirrors joinStyleValidationErrors for --writes:
|
||||
// every item's first error in one message, so the whole payload is fixed in
|
||||
// a single retry.
|
||||
func joinWritesValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return probs[0]
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
if p, ok := errs.ProblemOf(e); ok {
|
||||
msgs = append(msgs, p.Message)
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, e.Error())
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return common.ValidationErrorf("--writes has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
|
||||
}
|
||||
|
||||
func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -91,9 +234,13 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if err := checkCellsMatchRange(cells, rangeStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"range": rangeStr,
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
@@ -124,7 +271,11 @@ var CellsSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-style"),
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
|
||||
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
|
||||
},
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -625,6 +776,43 @@ func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
|
||||
// and returns its row / column counts. Errors on non-rectangular forms like
|
||||
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
|
||||
// from get_sheet_structure, outside the scope of pure local parsing.
|
||||
// checkCellsMatchRange rejects, before any network call, the cells-vs-range
|
||||
// mismatches the server would otherwise fail mid-batch ("cells row count (N)
|
||||
// does not match range row count (M)" — a recurring server-side error cluster
|
||||
// in eval traces, and the failure leaves earlier batch sub-ops applied).
|
||||
// Single-cell ranges are checked too: the server enforces the same strict
|
||||
// match on a bare "A1" (07-21 rerun, 12 rows against range row count 1) —
|
||||
// there is no anchor semantics on +cells-set. An unparsable range is the
|
||||
// range validator's job, not ours.
|
||||
func checkCellsMatchRange(cells []interface{}, rangeStr string) error {
|
||||
if len(cells) == 0 {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells is empty; to clear values use +cells-clear --scope content (needs --yes), or pass a non-empty 2D array")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // an unparsable range is reported by the range validation path with proper context
|
||||
}
|
||||
if len(cells) != rows {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells has %d rows but --range %q spans %d rows; make them equal (e.g. write N rows to an N-row range)",
|
||||
len(cells), rangeStr, rows)
|
||||
}
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] must be an array (one row of cells) — --cells is always a 2D array, a single cell is [[{…}]]", r)
|
||||
}
|
||||
if len(row) != cols {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] has %d columns but --range %q spans %d columns; every row must match the range width",
|
||||
r, len(row), rangeStr, cols)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
rangeStr = rangeStr[idx+1:]
|
||||
|
||||
71
shortcuts/sheets/read_output.go
Normal file
71
shortcuts/sheets/read_output.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet read → file offload ───────────────────────────────────
|
||||
//
|
||||
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
|
||||
// --output-path flag: when a caller redirects a read to a file, the char cap
|
||||
// should default to unlimited so the whole sheet lands on disk instead of being
|
||||
// clipped by the stdout-oriented max_chars safety cap.
|
||||
|
||||
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
|
||||
func readOutputPath(runtime *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(runtime.Str("output-path"))
|
||||
}
|
||||
|
||||
// maxCharsInput resolves the max_chars value to send to the underlying read
|
||||
// tool. With --output-path set the cap is lifted (unbounded sentinel) so the
|
||||
// full result is written to the file; otherwise the --max-chars value binds.
|
||||
// The second return is false when nothing should be sent (max-chars <= 0), in
|
||||
// which case the tool's own default applies. Note the tool truncates at ~50000
|
||||
// even when max_chars is omitted, so callers that want an explicit cap should
|
||||
// pass a positive default.
|
||||
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
|
||||
if readOutputPath(runtime) != "" {
|
||||
return unboundedReadLimit, true
|
||||
}
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// emitReadResult delivers a read shortcut's result. When --output-path is set it
|
||||
// writes the data payload to that path as pretty JSON and prints a small
|
||||
// confirmation envelope to stdout (path + byte count); otherwise it prints the
|
||||
// full result envelope to stdout as usual.
|
||||
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
|
||||
path := readOutputPath(runtime)
|
||||
if path == "" {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(path)
|
||||
if err != nil {
|
||||
resolved = path
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_path": resolved,
|
||||
"bytes_written": len(b),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -83,7 +84,7 @@ func callTool(
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
|
||||
WithCode(int(code))
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
@@ -100,6 +101,66 @@ func callTool(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
|
||||
// sheet-ai tools put in msg — batch_update in particular wraps its result as
|
||||
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
|
||||
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
|
||||
// naming each failed operation. Eval traces show agents (and even the eval
|
||||
// aggregator) failing to extract the real cause from the double-escaped
|
||||
// form. Anything that doesn't match the nested shape passes through
|
||||
// untouched.
|
||||
func flattenToolErrorMsg(msg string) string {
|
||||
trimmed := strings.TrimSpace(msg)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return msg
|
||||
}
|
||||
var outer struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
|
||||
return msg
|
||||
}
|
||||
inner := strings.TrimSpace(outer.Error)
|
||||
var detail struct {
|
||||
Message string `json:"message"`
|
||||
Failures []struct {
|
||||
Index int `json:"index"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Error string `json:"error"`
|
||||
} `json:"failures"`
|
||||
}
|
||||
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
|
||||
if len(detail.Failures) == 0 {
|
||||
return detail.Message
|
||||
}
|
||||
parts := make([]string, 0, len(detail.Failures))
|
||||
firstFailed := detail.Failures[0].Index
|
||||
for _, f := range detail.Failures {
|
||||
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
|
||||
if f.Index < firstFailed {
|
||||
firstFailed = f.Index
|
||||
}
|
||||
}
|
||||
out := detail.Message + " — " + strings.Join(parts, "; ")
|
||||
// Partial failure is NOT rolled back server-side: the succeeded sub-ops
|
||||
// stay applied. Spell out the recovery so agents don't resend the whole
|
||||
// batch and double-apply the successes (observed in eval traces). With
|
||||
// one failure (fail-fast) everything before it succeeded and nothing
|
||||
// after it ran — resend from that index; with several (continue-on-error)
|
||||
// only the listed failures need resending.
|
||||
if strings.Contains(detail.Message, "succeeded") &&
|
||||
!strings.Contains(detail.Message, " 0 succeeded") {
|
||||
if len(detail.Failures) == 1 {
|
||||
out += fmt.Sprintf("; note: succeeded operations stay applied (no rollback) — fix the failure and resend only operations[%d:] onward, do not resend the whole batch", firstFailed)
|
||||
} else {
|
||||
out += "; note: succeeded operations stay applied (no rollback) — fix and resend only the failed operations listed above, do not resend the whole batch"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
|
||||
// The wire-format body (with input serialized to a JSON string) is preserved
|
||||
// for fidelity, and a decoded tool_input map is surfaced alongside so humans
|
||||
|
||||
57
shortcuts/sheets/sheet_ai_api_flatten_test.go
Normal file
57
shortcuts/sheets/sheet_ai_api_flatten_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
|
||||
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
|
||||
// pass-through of everything else.
|
||||
func TestFlattenToolErrorMsg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("batch failures flatten to one line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
|
||||
got := flattenToolErrorMsg(msg)
|
||||
for _, want := range []string{
|
||||
"batch_update: 0 succeeded, 1 failed",
|
||||
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("flattened msg should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `\"`) {
|
||||
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain-string inner error unwraps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`)
|
||||
if got != `sheet "s" not found` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON msg passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
|
||||
if got := flattenToolErrorMsg(msg); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON without error field passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"detail":"x"}`
|
||||
if got := flattenToolErrorMsg(msg); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(all[i].PostMount)
|
||||
}
|
||||
// +chart-create grows --print-example (minimal per-type --properties
|
||||
// templates) — the biggest --print-schema consumer in eval traces.
|
||||
if all[i].Command == "+chart-create" {
|
||||
all[i].PostMount = withChartPrintExample(all[i].PostMount)
|
||||
}
|
||||
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
|
||||
// flags inlined, enum vocabulary normalization) ride the same
|
||||
// PostMount composition, so no other domain's behavior shifts.
|
||||
@@ -153,6 +158,9 @@ func shortcutList() []common.Shortcut {
|
||||
SparklineCreate, SparklineUpdate, SparklineDelete,
|
||||
FloatImageCreate, FloatImageUpdate, FloatImageDelete,
|
||||
|
||||
// lark_sheet_styles_put
|
||||
StylesPut,
|
||||
|
||||
// lark_sheet_batch_update
|
||||
BatchUpdate,
|
||||
CellsBatchSetStyle,
|
||||
|
||||
456
shortcuts/sheets/style_vocab.go
Normal file
456
shortcuts/sheets/style_vocab.go
Normal file
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── style vocabulary acceptance layer ────────────────────────────────
|
||||
//
|
||||
// The single home for how the sheets domain ACCEPTS style vocabulary, across
|
||||
// all three carrier paths that end in set_cell_range bodies:
|
||||
//
|
||||
// flag path +cells-set-style / +cells-batch-set-style flat flags
|
||||
// typed cells +cells-set --cells cell objects (incl. batch sub-ops)
|
||||
// styles payload --styles on +workbook-create / +table-put / +styles-put
|
||||
//
|
||||
// Design contract (established in the 2026-07 batch-update overhaul; see the
|
||||
// acceptance tests in styles_acceptance_test.go):
|
||||
//
|
||||
// - ONE canonical form, documented; a WIDE acceptance layer, undocumented.
|
||||
// Model priors are divergent (one eval batch produced six different
|
||||
// border spellings), so no canonical structure can make first tries
|
||||
// succeed — acceptance is normalized here instead, never per-call-site.
|
||||
// - Every rewrite must be unambiguous; ambiguous guesses (fore_color) get
|
||||
// a targeted prescription, never a silent pick. Silent ignoring and
|
||||
// bare rejection are both bugs.
|
||||
// - SILENT-ALIAS ADMISSION BAR (2026-07-21): only words from REAL external
|
||||
// vocabularies (Excel/openpyxl, CSS, Google Sheets API), recurring
|
||||
// across batches or ≥3 tasks in one, with zero semantic ambiguity.
|
||||
// Spelling/word-order permutations do NOT get aliases — they are
|
||||
// absorbed by the universal did-you-mean rejection (one self-healing
|
||||
// retry, zero per-variant code). Real vocabularies are a finite set;
|
||||
// permutations are not. Earlier permutation aliases are grandfathered.
|
||||
// - Closure is enforced by two test properties: vocabulary parity (every
|
||||
// flag-path style must be accepted on the payload paths) and the prior
|
||||
// corpus (every observed model spelling either normalizes or
|
||||
// prescribes). New eval finding → corpus row → fix HERE → locked.
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
// wrap family: word_wrap is the sole wrap concept, no ambiguity. 07-20
|
||||
// eval: wrap_text alone produced an 88-issue retry loop on --styles;
|
||||
// wrap_strategy (the Google Sheets API word) followed on 07-21.
|
||||
{"wrap_text", "word_wrap"},
|
||||
{"text_wrap", "word_wrap"},
|
||||
{"wrap_strategy", "word_wrap"},
|
||||
}
|
||||
|
||||
// cellStyleEnumFields sources the enum vocabulary for enum-bearing
|
||||
// cell_styles fields from the +cells-set-style flag-defs, so the payload path
|
||||
// (--styles / typed --cells) validates and canonicalizes values the same way
|
||||
// the cobra flag path does. 07-20 eval: "vertical_alignment":"center" (CSS
|
||||
// vocabulary; Lark spells it "middle") passed the CLI and burned a
|
||||
// server-side round trip ~10 times — the flag path had normalized it since
|
||||
// round 2, the payload path never did.
|
||||
func cellStyleEnumFields() map[string][]string {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" || df.Type != "string" || len(df.Enum) == 0 {
|
||||
continue
|
||||
}
|
||||
out[strings.ReplaceAll(df.Name, "-", "_")] = df.Enum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. It then canonicalizes enum VALUES (casing + known
|
||||
// cross-vocabulary aliases like CSS "center" → Lark "middle"; boolean
|
||||
// word_wrap → the enum) and rejects off-enum values client-side instead of
|
||||
// letting the server fail the whole batch. path labels the map for errors.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
// fore_color is deliberately NOT aliased: in openpyxl vocabulary fgColor
|
||||
// is the FILL color while a plain reading suggests the font color — a
|
||||
// silent pick could color the wrong thing. Prescribe both options.
|
||||
if _, has := style["fore_color"]; has {
|
||||
return common.ValidationErrorf("%s.fore_color is ambiguous — use font_color for text color or background_color for the cell fill", path)
|
||||
}
|
||||
// Boolean wrap habit: true unambiguously means wrap on, false means off.
|
||||
if b, isBool := style["word_wrap"].(bool); isBool {
|
||||
if b {
|
||||
style["word_wrap"] = "auto-wrap"
|
||||
} else {
|
||||
style["word_wrap"] = "overflow"
|
||||
}
|
||||
}
|
||||
for field, enum := range cellStyleEnumFields() {
|
||||
raw, has := style[field]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
val, isStr := raw.(string)
|
||||
if !isStr || val == "" || slices.Contains(enum, val) {
|
||||
continue
|
||||
}
|
||||
if canon := canonicalEnumValue(val, enum); canon != "" {
|
||||
style[field] = canon
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("%s.%s value %q is invalid (allowed: %s)", path, field, val, strings.Join(enum, ", "))
|
||||
if match := closestEnumValue(val, enum); match != "" {
|
||||
msg += fmt.Sprintf("; did you mean %q?", match)
|
||||
}
|
||||
return common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// It also expands the border "all" shorthand and intercepts border_styles
|
||||
// mis-nested inside cell_styles — both server-rejected shapes that eval
|
||||
// traces show surviving CLI validation and costing a full network round
|
||||
// trip. Structure is checked leniently to match the pass-through contract:
|
||||
// any element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// cells[][].style is the habitual spelling of cell_styles (recurring
|
||||
// server-side 900015206 in eval traces) — rewrite when unambiguous.
|
||||
if styleObj, isObj := cell["style"].(map[string]interface{}); isObj {
|
||||
if _, has := cell["cell_styles"]; has {
|
||||
return common.ValidationErrorf("%s[%d][%d].style conflicts with cell_styles; pass only cell_styles", path, r, c)
|
||||
}
|
||||
cell["cell_styles"] = styleObj
|
||||
delete(cell, "style")
|
||||
}
|
||||
// cells[][].type is not a cell field; the value type is whatever the
|
||||
// JSON value is. Reject with the fix instead of a server round trip.
|
||||
if _, has := cell["type"]; has {
|
||||
return common.ValidationErrorf("%s[%d][%d].type is not a cell field — the value type is inferred from the JSON value; control display format via cell_styles.number_format", path, r, c)
|
||||
}
|
||||
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
|
||||
expandBorderAllShorthand(bs)
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, misNested := st["border_styles"]; misNested {
|
||||
return common.ValidationErrorf(
|
||||
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
|
||||
path, r, c)
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
|
||||
// Excel / openpyxl vocabulary, rejected by the backend — into the four
|
||||
// explicit sides, in place. An explicitly set side wins over the shorthand.
|
||||
// Applied on both the typed --cells path and the --styles path, so batch
|
||||
// sub-ops get the same rewrite as standalone calls.
|
||||
func expandBorderAllShorthand(border map[string]interface{}) {
|
||||
if all, ok := border["all"]; ok {
|
||||
for _, side := range []string{"top", "bottom", "left", "right"} {
|
||||
if _, exists := border[side]; !exists {
|
||||
border[side] = all
|
||||
}
|
||||
}
|
||||
delete(border, "all")
|
||||
}
|
||||
// Weight vocabulary in the style slot ("thin"/"medium"/"thick" are the
|
||||
// habitual Excel words; the largest residual styles cluster in the 07-21
|
||||
// rerun wrote them into border_styles.<side>.style of the FULL nested
|
||||
// form). A thin border always means a thin solid line: move the word to
|
||||
// weight and default style to solid. Only when weight is absent — an
|
||||
// explicit conflicting weight keeps the enum error path.
|
||||
for _, raw := range border {
|
||||
side, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := side["style"].(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "thin", "medium", "thick":
|
||||
if _, hasWeight := side["weight"]; !hasWeight {
|
||||
side["weight"] = strings.ToLower(s)
|
||||
side["style"] = "solid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects), expanding the "all" side shorthand the
|
||||
// same as the typed --cells and --styles paths so +cells-set-style /
|
||||
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
|
||||
// Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
// foldBorderFamilyAliases rewrites the habitual flattened border vocabulary
|
||||
// (Excel / openpyxl conventions) into the canonical nested border_styles
|
||||
// object, in place. 07-20 eval: the border family alone accounted for the
|
||||
// largest --styles error cluster (borders / border / border_bottom /
|
||||
// border_style / border_top_color / …), each burning a full payload retry.
|
||||
// Accepted rewrites, all unambiguous:
|
||||
//
|
||||
// borders / border (object) → border_styles (side-keyed) or border_styles.all (attr-keyed)
|
||||
// border_top|bottom|left|right (object) → border_styles.<side>
|
||||
// border_style|color|weight (scalar) → border_styles.all.<attr>
|
||||
// border_<side>_<style|color|weight> (scalar) → border_styles.<side>.<attr>
|
||||
//
|
||||
// A border_style value from the WEIGHT vocabulary (thin/medium/thick — the
|
||||
// habitual Excel word) sets weight and defaults style to solid: a "thin
|
||||
// border" always means a thin solid line. Conflicts with an explicitly given
|
||||
// border_styles error out instead of picking a side.
|
||||
func foldBorderFamilyAliases(in map[string]interface{}, path string) error {
|
||||
sides := map[string]bool{"top": true, "bottom": true, "left": true, "right": true, "all": true}
|
||||
attrs := map[string]bool{"style": true, "color": true, "weight": true}
|
||||
borderWeights := map[string]bool{"thin": true, "medium": true, "thick": true}
|
||||
|
||||
ensureBorder := func() map[string]interface{} {
|
||||
bs, ok := in["border_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
bs = map[string]interface{}{}
|
||||
in["border_styles"] = bs
|
||||
}
|
||||
return bs
|
||||
}
|
||||
setSideAttr := func(side, attr string, v interface{}, from string) error {
|
||||
bs := ensureBorder()
|
||||
sideObj, ok := bs[side].(map[string]interface{})
|
||||
if !ok {
|
||||
if _, exists := bs[side]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s; keep one form", path, from, side)
|
||||
}
|
||||
sideObj = map[string]interface{}{}
|
||||
bs[side] = sideObj
|
||||
}
|
||||
if _, exists := sideObj[attr]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s.%s; keep one form", path, from, side, attr)
|
||||
}
|
||||
sideObj[attr] = v
|
||||
return nil
|
||||
}
|
||||
setSide := func(side string, v interface{}, from string) error {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.ValidationErrorf("%s.%s must be an object like {\"style\":\"solid\",\"color\":\"#000000\"}", path, from)
|
||||
}
|
||||
for attr, av := range obj {
|
||||
if !attrs[attr] {
|
||||
return common.ValidationErrorf("%s.%s.%s is not a border attribute (want style/weight/color)", path, from, attr)
|
||||
}
|
||||
if err := setSideAttr(side, attr, av, from); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// border_style with a weight-vocabulary value means "thin solid line".
|
||||
setAllScalar := func(attr string, v interface{}, from string) error {
|
||||
if attr == "style" {
|
||||
if s, ok := v.(string); ok && borderWeights[strings.ToLower(s)] {
|
||||
if err := setSideAttr("all", "weight", strings.ToLower(s), from); err != nil {
|
||||
return err
|
||||
}
|
||||
return setSideAttr("all", "style", "solid", from)
|
||||
}
|
||||
}
|
||||
return setSideAttr("all", attr, v, from)
|
||||
}
|
||||
|
||||
for _, key := range []string{"borders", "border"} {
|
||||
v, has := in[key]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.ValidationErrorf("%s.%s must be an object — either side-keyed ({\"top\":{…},\"bottom\":{…}} / {\"all\":{…}}) or attribute-keyed ({\"style\":\"solid\",\"color\":\"#000\"} = all four sides)", path, key)
|
||||
}
|
||||
sideKeyed := false
|
||||
for k := range obj {
|
||||
if sides[k] {
|
||||
sideKeyed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if sideKeyed {
|
||||
for side, sv := range obj {
|
||||
if !sides[side] {
|
||||
return common.ValidationErrorf("%s.%s.%s is not a valid side (want top/bottom/left/right/all)", path, key, side)
|
||||
}
|
||||
if err := setSide(side, sv, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if err := setSide("all", v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
for _, side := range []string{"top", "bottom", "left", "right"} {
|
||||
// Both word orders appear in the wild: border_bottom (07-20 eval) and
|
||||
// bottom_border (07-21), same for the flattened attribute triples.
|
||||
for _, key := range []string{"border_" + side, side + "_border"} {
|
||||
if v, has := in[key]; has {
|
||||
if err := setSide(side, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
for attr := range attrs {
|
||||
for _, key := range []string{"border_" + side + "_" + attr, side + "_border_" + attr} {
|
||||
if v, has := in[key]; has {
|
||||
if err := setSideAttr(side, attr, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for attr := range attrs {
|
||||
key := "border_" + attr
|
||||
if v, has := in[key]; has {
|
||||
if err := setAllScalar(attr, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
410
shortcuts/sheets/styles_acceptance_test.go
Normal file
410
shortcuts/sheets/styles_acceptance_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── styles acceptance contract ───────────────────────────────────────
|
||||
//
|
||||
// Two closure properties that turn the --styles acceptance surface from
|
||||
// "endless patching" into a locked contract (07-20 rerun lesson: the
|
||||
// redesign moved traffic onto the payload path while the flag path's
|
||||
// forgiveness layers stayed behind):
|
||||
//
|
||||
// 1. Vocabulary parity — every style the flag path (+cells-set-style)
|
||||
// can express must be accepted verbatim by the payload path.
|
||||
// 2. Prior corpus — every model spelling observed in eval traces must
|
||||
// either normalize to the canonical form or produce a targeted
|
||||
// prescription. Silent ignoring and bare rejection are both bugs.
|
||||
// New eval finding → add a corpus row → fix → locked forever.
|
||||
|
||||
// acceptStyleItem runs one cell_styles item through the styles-put pipeline
|
||||
// and returns the emitted cell prototype (cell_styles/border_styles) or the
|
||||
// error.
|
||||
func acceptStyleItem(t *testing.T, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
t.Helper()
|
||||
item := map[string]interface{}{"range": "A1:B2"}
|
||||
for k, v := range fields {
|
||||
item[k] = v
|
||||
}
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// TestStylesAcceptance_VocabularyParity locks property 1: iterate the
|
||||
// +cells-set-style flag vocabulary from flag-defs and assert the payload
|
||||
// path accepts each field with a valid value and emits it.
|
||||
func TestStylesAcceptance_VocabularyParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs: %v", err)
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
t.Fatal("no +cells-set-style flag defs")
|
||||
}
|
||||
sample := func(df flagDef) interface{} {
|
||||
if len(df.Enum) > 0 {
|
||||
return df.Enum[0]
|
||||
}
|
||||
switch df.Type {
|
||||
case "float64", "int":
|
||||
return float64(12)
|
||||
}
|
||||
switch df.Name {
|
||||
case "font-family":
|
||||
return "Arial"
|
||||
case "number-format":
|
||||
return "0.00"
|
||||
default: // colors and any future string field
|
||||
return "#112233"
|
||||
}
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" || df.Name == "range" {
|
||||
continue
|
||||
}
|
||||
df := df
|
||||
t.Run(df.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
field := strings.ReplaceAll(df.Name, "-", "_")
|
||||
var value interface{}
|
||||
if df.Name == "border-styles" {
|
||||
value = map[string]interface{}{"all": map[string]interface{}{"style": "solid"}}
|
||||
} else {
|
||||
value = sample(df)
|
||||
}
|
||||
proto, err := acceptStyleItem(t, map[string]interface{}{field: value})
|
||||
if err != nil {
|
||||
t.Fatalf("payload path rejects flag-path field %s: %v", field, err)
|
||||
}
|
||||
if df.Name == "border-styles" {
|
||||
if _, ok := proto["border_styles"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("border_styles not emitted: %v", proto)
|
||||
}
|
||||
return
|
||||
}
|
||||
cs, _ := proto["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs[field] == nil {
|
||||
t.Fatalf("field %s silently dropped: %v", field, proto)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stylesPriorCorpus is the observed-model-spelling corpus (source: eval
|
||||
// batches 2026-07-08 → 07-20). Every row must either normalize (checked via
|
||||
// wantCell) or produce a targeted prescription (wantErr). Add a row for every
|
||||
// new spelling an eval surfaces — never let one be silently ignored.
|
||||
var stylesPriorCorpus = []struct {
|
||||
name string
|
||||
fields map[string]interface{}
|
||||
wantErr string // "" = must be accepted
|
||||
check func(proto map[string]interface{}) string // "" = ok, else failure detail
|
||||
}{
|
||||
// border family (07-20: largest cluster)
|
||||
{name: "borders attr-keyed means all sides",
|
||||
fields: map[string]interface{}{"borders": map[string]interface{}{"style": "solid", "color": "#DDDDDD"}},
|
||||
check: wantBorder("top", "style", "solid")},
|
||||
{name: "border side-keyed",
|
||||
fields: map[string]interface{}{"border": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}}},
|
||||
check: wantBorder("top", "style", "solid")},
|
||||
{name: "border_bottom object",
|
||||
fields: map[string]interface{}{"border_bottom": map[string]interface{}{"style": "solid"}},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "border_style weight-vocabulary means thin solid",
|
||||
fields: map[string]interface{}{"border_style": "thin"},
|
||||
check: wantBorder("top", "weight", "thin")},
|
||||
{name: "border_style style-vocabulary",
|
||||
fields: map[string]interface{}{"border_style": "dashed"},
|
||||
check: wantBorder("top", "style", "dashed")},
|
||||
{name: "border_color scalar",
|
||||
fields: map[string]interface{}{"border_color": "#FF0000"},
|
||||
check: wantBorder("top", "color", "#FF0000")},
|
||||
{name: "border_top_color flattened",
|
||||
fields: map[string]interface{}{"border_top_color": "#FF0000"},
|
||||
check: wantBorder("top", "color", "#FF0000")},
|
||||
{name: "border_left_weight flattened",
|
||||
fields: map[string]interface{}{"border_left_weight": "thin"},
|
||||
check: wantBorder("left", "weight", "thin")},
|
||||
{name: "border_styles invalid side prescribed",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"outer": map[string]interface{}{"style": "solid"}}},
|
||||
wantErr: "not a valid side"},
|
||||
// wrap family
|
||||
{name: "wrap_text boolean", fields: map[string]interface{}{"wrap_text": true}, check: wantStyle("word_wrap", "auto-wrap")},
|
||||
{name: "text_wrap string", fields: map[string]interface{}{"text_wrap": "auto-wrap"}, check: wantStyle("word_wrap", "auto-wrap")},
|
||||
{name: "word_wrap false", fields: map[string]interface{}{"word_wrap": false}, check: wantStyle("word_wrap", "overflow")},
|
||||
// alignment family
|
||||
{name: "horizontal_align shorthand", fields: map[string]interface{}{"horizontal_align": "center"}, check: wantStyle("horizontal_alignment", "center")},
|
||||
{name: "valign shorthand", fields: map[string]interface{}{"valign": "top"}, check: wantStyle("vertical_alignment", "top")},
|
||||
{name: "CSS center for vertical", fields: map[string]interface{}{"vertical_alignment": "center"}, check: wantStyle("vertical_alignment", "middle")},
|
||||
{name: "casing normalized", fields: map[string]interface{}{"font_weight": "BOLD"}, check: wantStyle("font_weight", "bold")},
|
||||
// weight vocabulary in the FULL nested form's style slot (07-21 rerun:
|
||||
// the dominant residual — 8 tasks wrote border_styles.<side>.style:"thin")
|
||||
{name: "full-form thin in style slot",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "thin"}}},
|
||||
check: wantBorder("top", "weight", "thin")},
|
||||
{name: "full-form all-shorthand medium in style slot",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"all": map[string]interface{}{"style": "medium"}}},
|
||||
check: wantBorder("bottom", "weight", "medium")},
|
||||
// side-first word order + Google Sheets wrap word (07-21 evening batch)
|
||||
{name: "side-first bottom_border object",
|
||||
fields: map[string]interface{}{"bottom_border": map[string]interface{}{"style": "solid"}},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "side-first bottom_border_style scalar",
|
||||
fields: map[string]interface{}{"bottom_border_style": "solid"},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "wrap_strategy aliases to word_wrap",
|
||||
fields: map[string]interface{}{"wrap_strategy": "auto-wrap"},
|
||||
check: wantStyle("word_wrap", "auto-wrap")},
|
||||
// prescriptions (ambiguous / unsupported / typo)
|
||||
{name: "fore_color prescribed", fields: map[string]interface{}{"fore_color": "#F00"}, wantErr: "ambiguous"},
|
||||
{name: "indent rejected not ignored", fields: map[string]interface{}{"indent": float64(2)}, wantErr: "not a supported style field"},
|
||||
{name: "unknown field carries did-you-mean and the field list",
|
||||
fields: map[string]interface{}{"fontcolor": "#000000"}, wantErr: `did you mean "font_color"`},
|
||||
{name: "enum typo gets did-you-mean", fields: map[string]interface{}{"vertical_alignment": "botom"}, wantErr: "did you mean"},
|
||||
}
|
||||
|
||||
func wantStyle(field, want string) func(map[string]interface{}) string {
|
||||
return func(proto map[string]interface{}) string {
|
||||
cs, _ := proto["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs[field] != want {
|
||||
return fmt.Sprintf("cell_styles.%s = %v, want %q", field, cs[field], want)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func wantBorder(side, attr, want string) func(map[string]interface{}) string {
|
||||
return func(proto map[string]interface{}) string {
|
||||
bs, _ := proto["border_styles"].(map[string]interface{})
|
||||
sideObj, _ := bs[side].(map[string]interface{})
|
||||
if sideObj == nil || sideObj[attr] != want {
|
||||
return fmt.Sprintf("border_styles.%s.%s = %v, want %q", side, attr, sideObj[attr], want)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TestStylesAcceptance_PriorCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range stylesPriorCorpus {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
proto, err := acceptStyleItem(t, tc.fields)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("want prescription containing %q, got err=%v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("corpus spelling rejected: %v", err)
|
||||
}
|
||||
if detail := tc.check(proto); detail != "" {
|
||||
t.Fatal(detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPut_CoalescesSameStyleRanges pins the declarative-spec
|
||||
// optimization: per-row entries with the identical style fuse into one
|
||||
// rectangle, so row-by-row specs (07-21 rerun: 184/203/861-op expansions
|
||||
// against the 100-op cap) no longer hit the cap.
|
||||
func TestStylesPut_CoalescesSameStyleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("150 same-style rows fuse into one stamp", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries := make([]interface{}, 0, 150)
|
||||
for r := 1; r <= 150; r++ {
|
||||
entries = append(entries, map[string]interface{}{
|
||||
"range": fmt.Sprintf("A%d:F%d", r, r), "font_weight": "bold",
|
||||
})
|
||||
}
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("got %d ops, want 1 fused stamp", len(ops))
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:F150" {
|
||||
t.Fatalf("range = %v, want A1:F150", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different styles stay separate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:F1", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "A2:F2", "background_color": "#EEEEEE"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 2 {
|
||||
t.Fatalf("ops=%d err=%v, want 2", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("horizontal fuse with same rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:C5", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "D1:F5", "font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v, want 1", len(ops), err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:F5" {
|
||||
t.Fatalf("range = %v, want A1:F5", input["range"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTypedCellsHabitualKeys pins the typed --cells cell-object fixes
|
||||
// (recurring server-side 900015206 across 07-20/07-21 reruns).
|
||||
func TestTypedCellsHabitualKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("style object rewrites to cell_styles through batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
||||
"value": "x", "style": map[string]interface{}{"font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
cell := input["cells"].([]interface{})[0].([]interface{})[0].(map[string]interface{})
|
||||
cs, _ := cell["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell = %v, want cell_styles.font_weight bold", cell)
|
||||
}
|
||||
if _, has := cell["style"]; has {
|
||||
t.Fatalf("style key must be renamed, got %v", cell)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type key gets a prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
||||
"value": "x", "type": "text",
|
||||
}}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not a cell field")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesAcceptance_ResizeAndMergeCorpus extends the corpus to the
|
||||
// row/col_sizes and cell_merges sections.
|
||||
func TestStylesAcceptance_ResizeAndMergeCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runSection := func(section string, entry interface{}) ([]interface{}, error) {
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
section: []interface{}{entry},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
pixelValue := func(t *testing.T, ops []interface{}, key string) interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block, _ := input[key].(map[string]interface{})
|
||||
if block == nil || block["type"] != "pixel" {
|
||||
t.Fatalf("%s = %v, want pixel block", key, input[key])
|
||||
}
|
||||
return block["value"]
|
||||
}
|
||||
|
||||
t.Run("size alone implies pixel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "size": float64(36)})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v := pixelValue(t, ops, "resize_height"); v != 36 {
|
||||
t.Fatalf("value = %v, want 36", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width alone implies pixel on col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "width": float64(120)})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v := pixelValue(t, ops, "resize_width"); v != 120 {
|
||||
t.Fatalf("value = %v, want 120", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type auto still works on rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "type": "auto"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("neither size nor type prescribed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runSection("row_sizes", map[string]interface{}{"range": "1:1"})
|
||||
requireValidation(t, err, "needs size (px) or type")
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word prescribed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "height": float64(36)})
|
||||
requireValidation(t, err, "does not apply")
|
||||
})
|
||||
|
||||
t.Run("raw OpenAPI merge_type accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("cell_merges", map[string]interface{}{"range": "A1:B2", "merge_type": "MERGE_ALL"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["merge_type"] != "all" {
|
||||
t.Fatalf("merge_type = %v, want all", input["merge_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare string merge accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := runSection("cell_merges", "A1:B2"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
250
shortcuts/sheets/styles_prescription_test.go
Normal file
250
shortcuts/sheets/styles_prescription_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
|
||||
// --styles: every issue across sections and ops is reported in a single
|
||||
// error (eval V2U032 burned three round trips fixing a border side, then
|
||||
// row_sizes.type, then size — each surfaced only after the previous fix).
|
||||
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s",
|
||||
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
|
||||
"row_sizes":[{"range":"1:1","type":"custom"}],
|
||||
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--styles has 3 issues")
|
||||
for _, want := range []string{
|
||||
"border_styles.horizontal is not a valid side",
|
||||
`row_sizes[0].type "custom" is invalid`,
|
||||
"col_sizes[0].type pixel requires size",
|
||||
} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
// D2: each type/size error inlines a complete valid op.
|
||||
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
|
||||
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
|
||||
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
|
||||
// rewritten to four explicit sides instead of being rejected (or worse,
|
||||
// passed through for the server to reject, as happened on the typed-cells
|
||||
// path in eval V2U013/V2U021).
|
||||
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
// table-put's dry-run body carries the tool input as an escaped JSON
|
||||
// string, so match the escaped key form.
|
||||
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `\"all\"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
|
||||
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
|
||||
// cell_styles is intercepted with a move-it prescription instead of a
|
||||
// server-side 900015206.
|
||||
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("border all expands", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","border_styles":{"all":{"style":"solid"}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand and pass, got: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
|
||||
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
|
||||
if !strings.Contains(ve.Message, "sibling of cell_styles") {
|
||||
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
|
||||
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
|
||||
// not the typed --cells or --styles walkers): the "all" shorthand must expand
|
||||
// CLI-side here too, or the backend rejects {"all":…}.
|
||||
func TestCellsSetStyle_BorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
|
||||
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
|
||||
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-merge")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:B2",
|
||||
"--merge-type", "MERGE_ALL",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
|
||||
// auto-wrap (eval V2U029).
|
||||
func TestCellsSetStyle_WordWrapBooleanNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--word-wrap", "true",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "auto-wrap") {
|
||||
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
|
||||
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
|
||||
// field names out of JSON payloads where underscores are canonical).
|
||||
func TestUnderscoreFlagFormsParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet_name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--font_weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "bold") {
|
||||
t.Errorf("dry-run body should carry the style, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
|
||||
// resolving the border-styles schema (eval V2U013 burned a retry on this).
|
||||
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+cells-set-style")
|
||||
out, err := print("border_styles")
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected schema output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
|
||||
// contract on the real embedded chart schema: a dotted --flag-name returns
|
||||
// just that subtree, and a path miss lists the keys actually available.
|
||||
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
t.Run("slices a nested subtree", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("dotted path should slice, got: %v", err)
|
||||
}
|
||||
full, err2 := print("properties")
|
||||
if err2 != nil {
|
||||
t.Fatalf("full dump: %v", err2)
|
||||
}
|
||||
if len(out) == 0 || len(out) >= len(full) {
|
||||
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path miss lists available keys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nosuchkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown path segment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "available keys:") {
|
||||
t.Errorf("error should list available keys, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -79,27 +79,42 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Step 2: query minute_token via recording API
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
if minuteErr != nil {
|
||||
// Recording API failed — surface the error but keep data from step 1
|
||||
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
minuteHint = ""
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
// Step 2: query minute_token via recording API — only meaningful once the
|
||||
// meeting has ended. While it is still in progress the note/minute are not
|
||||
// generated yet, so skip the recording call and surface an informational
|
||||
// hint instead of letting an unclassified recording error fail the command.
|
||||
inProgress := meetingInProgress(meeting)
|
||||
var minuteHint string
|
||||
if inProgress {
|
||||
minuteHint = "meeting is still in progress; note and minute are not generated yet"
|
||||
} else {
|
||||
minuteToken, hint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
minuteHint = hint
|
||||
if minuteErr != nil {
|
||||
// Recording lookup is a best-effort supplement; step 1 already
|
||||
// succeeded, so degrade the failure to a hint rather than failing
|
||||
// the whole command.
|
||||
minuteHint = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
}
|
||||
}
|
||||
|
||||
// Add hints for empty resources (not errors, just informational)
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
// Add hints for empty resources (not errors, just informational). For an
|
||||
// in-progress meeting the "not found" wording is noise, so we only emit the
|
||||
// single in-progress hint below.
|
||||
if !inProgress {
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
}
|
||||
if minuteHint != "" {
|
||||
if result.Hint != "" {
|
||||
@@ -112,6 +127,36 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
|
||||
return result
|
||||
}
|
||||
|
||||
// meetingTimeField reads a meeting time field as a string regardless of whether
|
||||
// the API returned it as a JSON string or number. VC serializes int64
|
||||
// timestamps as strings, but coercing via %v keeps parsing robust either way;
|
||||
// float64(0) renders as "0", which parseFlexibleTime treats as "absent".
|
||||
func meetingTimeField(meeting map[string]any, key string) string {
|
||||
v, ok := meeting[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
// meetingInProgress reports whether a meeting is still ongoing, using the same
|
||||
// start/end heuristic as +meeting-events (meetingEventsMeetingFromPayload): a
|
||||
// meeting is ongoing when it has a start time but no end time, or its end time
|
||||
// is not after its start time. It reads the RAW timestamp fields, not the
|
||||
// FormatTime-rendered result strings, because parseFlexibleTime only accepts
|
||||
// Unix timestamps or RFC3339. Empty or "0" values are treated as absent.
|
||||
func meetingInProgress(meeting map[string]any) bool {
|
||||
start, hasStart := parseFlexibleTime(meetingTimeField(meeting, "start_time"))
|
||||
end, hasEnd := parseFlexibleTime(meetingTimeField(meeting, "end_time"))
|
||||
if !hasStart {
|
||||
return false
|
||||
}
|
||||
if !hasEnd {
|
||||
return true
|
||||
}
|
||||
return !end.After(start)
|
||||
}
|
||||
|
||||
// VCDetail gets meeting details including note_id and minute_token.
|
||||
var VCDetail = common.Shortcut{
|
||||
Service: "vc",
|
||||
|
||||
@@ -269,11 +269,58 @@ func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
|
||||
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty: a recording lookup failure must not fail the command", result.Error)
|
||||
}
|
||||
if strings.Contains(result.Hint, "minute_token") {
|
||||
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
|
||||
if !strings.Contains(result.Hint, "failed to query minutes") || !strings.Contains(result.Hint, "weird API error") {
|
||||
t.Errorf("hint = %q, want contains 'failed to query minutes' and 'weird API error'", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMeetingDetail_MeetingInProgress pins the in-progress behavior: when a
|
||||
// meeting is still ongoing (end_time not after start_time), +detail must not
|
||||
// call the recording API at all — it returns meeting metadata with an
|
||||
// informational hint and no error. Deliberately register NO recording stub so
|
||||
// that any recording call would fail on an unmatched request.
|
||||
func TestFetchMeetingDetail_MeetingInProgress(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_live",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"meeting": map[string]interface{}{
|
||||
"id": "m_live",
|
||||
"topic": "Live Meeting",
|
||||
"meeting_no": "912052453",
|
||||
// end_time == start_time signals an ongoing meeting.
|
||||
"start_time": "1752000000",
|
||||
"end_time": "1752000000",
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
if err := botExec(t, "detail-live", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_live")
|
||||
if result.Topic != "Live Meeting" {
|
||||
t.Errorf("topic = %q, want 'Live Meeting'", result.Topic)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty for an in-progress meeting", result.Error)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty for an in-progress meeting", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Hint, "in progress") {
|
||||
t.Errorf("hint = %q, want to mention the meeting is in progress", result.Hint)
|
||||
}
|
||||
if strings.Contains(result.Hint, "not found for this meeting") {
|
||||
t.Errorf("hint = %q, should not emit not-found noise for an in-progress meeting", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `form` | 否 | 表单值,**JSON 数组字符串**,不是普通对象;API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
`instances create --data.form` 是可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
@@ -173,7 +171,6 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 按发起时间范围筛选(秒级时间戳)
|
||||
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 按任务时间范围筛选(秒级时间戳)
|
||||
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
| `tasks[].instance_external_id` | 三方审批实例 ID,仅第三方审批实例存在 |
|
||||
| `tasks[].task_external_id` | 三方审批任务 ID,仅第三方审批任务存在 |
|
||||
| `tasks[].link` | 三方审批跳转链接 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
|
||||
@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 退回到发起节点(发起节点 ID 为 START)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
|
||||
@@ -16,15 +16,20 @@
|
||||
|
||||
## 2. 各类型 CellValue
|
||||
|
||||
### 2.1 text / phone / url
|
||||
### 2.1 text
|
||||
|
||||
用字符串。URL 字段也传 URL 字符串;普通文本里可以保留 Markdown 风格链接文本,平台会按字段类型处理。
|
||||
text 字段的 `style.type` 影响单元格检查逻辑:
|
||||
`type=plain` 传 Markdown 格式的字符串。
|
||||
`type=url` 传一个带 title 的 Markdown 格式链接,或单独传一个链接。
|
||||
`type=phone` 传合法电话号码。
|
||||
`type=email` 传合法邮箱字符串。
|
||||
|
||||
```json
|
||||
{
|
||||
"标题": "Hello",
|
||||
"标题": "Hello, [lark-cli](https://github.com/larksuite/cli)",
|
||||
"官网": "[官网](https://example.com)",
|
||||
"联系电话": "1380000000000",
|
||||
"官网": "https://example.com"
|
||||
"邮箱": "owner@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ lark-cli base +field-create \
|
||||
lark-cli base +field-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Todo"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
|
||||
lark-cli base +field-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -51,6 +51,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。
|
||||
- 顶层最少包含:`name`、`type`。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。
|
||||
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;`datetime` / `user` 的动态填充用 `$slot`。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
|
||||
- `type` 不同,必填子字段不同:
|
||||
- `select`:`multiple` 控制是否多选,`options` 定义静态选项,`dynamic_options_source` 定义动态选项来源。静态与动态选项配置二选一,不能同时传。
|
||||
- `link`:必须有 `link_table`,可选 `bidirectional`、`bidirectional_link_field_name`。
|
||||
@@ -64,6 +65,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
"name": "状态",
|
||||
"type": "select",
|
||||
"multiple": false,
|
||||
"default_value": ["Todo"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Done", "hue": "Green", "lightness": "Light" }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层统一使用:`type` + `name` + 类型特有字段。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
|
||||
- 字段默认值使用 `default_value`,直接传对应 CellValue;支持范围只有 `text`、`number`、静态 `select`、`datetime`、`user`。清空默认值传 `null`;省略表示创建时不设置、更新时不修改。
|
||||
- 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。
|
||||
- `+field-update` 使用同样的字段 JSON 结构,但语义是 `PUT`;这是高风险写入操作,建议先 `+field-get` 再按目标状态全量提交,并带 `--yes`。
|
||||
- `type=formula` 或 `type=lookup` 创建/更新前,必须先读对应 guide。
|
||||
@@ -27,12 +28,12 @@
|
||||
|
||||
| 类型 | 最小必填字段 | 常见补充字段 |
|
||||
|------|--------------|-------------|
|
||||
| `text` | `type` `name` | `style.type` |
|
||||
| `number` | `type` `name` | `style` |
|
||||
| `select` | `type` `name` | `multiple` + `options`,或 `multiple` + `dynamic_options_source` |
|
||||
| `datetime` | `type` `name` | `style.format` |
|
||||
| `text` | `type` `name` | `style.type` `default_value` |
|
||||
| `number` | `type` `name` | `style` `default_value` |
|
||||
| `select` | `type` `name` | `multiple` + `options` + 静态 `default_value`,或 `multiple` + `dynamic_options_source` |
|
||||
| `datetime` | `type` `name` | `style.format` `default_value` |
|
||||
| `created_at` / `updated_at` | `type` `name` | `style.format` |
|
||||
| `user` / `group_chat` | `type` `name` | `multiple` |
|
||||
| `user` / `group_chat` | `type` `name` | `multiple`;仅 `user` 支持 `default_value` |
|
||||
| `created_by` / `updated_by` | `type` `name` | 无 |
|
||||
| `link` | `type` `name` `link_table` | `bidirectional` `bidirectional_link_field_name` |
|
||||
| `formula` | `type` `name` `expression` | 无 |
|
||||
@@ -47,31 +48,37 @@
|
||||
### 3.1 text
|
||||
|
||||
文本字段;电话、超链接、邮箱、条码也都属于 `text`,通过 `style.type` 区分。
|
||||
支持 `default_value`:静态 Markdown 文本字符串;`phone` style 必须是合法电话号码;`url` style 传一个 Markdown 链接或裸 URL;`email` style 必须是合法邮箱字符串,不要传 Markdown 链接或 `mailto:`。
|
||||
|
||||
最小写法(默认 `style.type` 为 `plain`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "标题"
|
||||
"name": "标题",
|
||||
"default_value": "默认标题"
|
||||
}
|
||||
```
|
||||
|
||||
常用写法:
|
||||
|
||||
默认值可以是 Markdown 文本
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "标题",
|
||||
"description": "主标题字段"
|
||||
"description": "主标题字段",
|
||||
"default_value": "未命名"
|
||||
}
|
||||
```
|
||||
|
||||
`style.type=phone` 时默认值是合法电话号码字符串。
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "联系电话",
|
||||
"style": { "type": "phone" }
|
||||
"style": { "type": "phone" },
|
||||
"default_value": "+8613800000000"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,7 +86,17 @@
|
||||
{
|
||||
"type": "text",
|
||||
"name": "官网",
|
||||
"style": { "type": "url" }
|
||||
"style": { "type": "url" },
|
||||
"default_value": "[官网](https://example.com)"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "邮箱",
|
||||
"style": { "type": "email" },
|
||||
"default_value": "owner@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -88,13 +105,15 @@
|
||||
### 3.2 number
|
||||
|
||||
数字字段;货币、进度、评分都属于 `number`,通过 `style.type` 区分。
|
||||
支持 `default_value`:静态 JSON number;所有 number style 都按这个规则写。
|
||||
|
||||
最小写法(默认 `style.type` 为 `plain`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "number",
|
||||
"name": "工时"
|
||||
"name": "工时",
|
||||
"default_value": 8
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,7 +137,8 @@
|
||||
"precision": 2,
|
||||
"percentage": false,
|
||||
"thousands_separator": true
|
||||
}
|
||||
},
|
||||
"default_value": 8
|
||||
}
|
||||
```
|
||||
|
||||
@@ -151,7 +171,8 @@
|
||||
{
|
||||
"type": "number",
|
||||
"name": "完成度",
|
||||
"style": { "type": "progress", "percentage": true, "color": "Blue" }
|
||||
"style": { "type": "progress", "percentage": true, "color": "Blue" },
|
||||
"default_value": 0.65
|
||||
}
|
||||
```
|
||||
|
||||
@@ -180,6 +201,7 @@
|
||||
#### 静态选项
|
||||
|
||||
支持字段:`multiple`、`options`
|
||||
支持 `default_value`:静态选项名数组;即使 `multiple=false` 也写数组,如 `["Todo"]`。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `false`
|
||||
@@ -189,12 +211,14 @@
|
||||
- `options[].hue` 可用:`Red`、`Orange`、`Yellow`、`Lime`、`Green`、`Turquoise`、`Wathet`、`Blue`、`Carmine`、`Purple`、`Gray` 缺省值为 `Blue`
|
||||
- `options[].lightness` 可用:`Lighter`、`Light`、`Standard`、`Dark`、`Darker` 缺省值为 `Lighter`
|
||||
- 选项里没有 `id`,只有 `name`。
|
||||
- 支持 `default_value` 配置:填选项名数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "select",
|
||||
"name": "状态",
|
||||
"multiple": false,
|
||||
"default_value": ["Todo"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Done", "hue": "Green", "lightness": "Light" }
|
||||
@@ -205,6 +229,7 @@
|
||||
#### 动态选项
|
||||
|
||||
支持字段:`multiple`、`dynamic_options_source`
|
||||
动态选项不支持 `default_value`。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `false`
|
||||
@@ -213,6 +238,7 @@
|
||||
- `dynamic_options_source.field_id` 填来源字段 id 或字段名
|
||||
- `dynamic_options_source` 仅创建支持;更新已有字段时不要传
|
||||
- 引用选项条件 / 级联筛选条件:这个功能在 Base 前端支持,属于 UI-only 属性,OpenAPI 里不支持,CLI 不能读取、创建或更新;不要根据接口返回缺失判断未配置
|
||||
- 动态选项不支持配置 `default_value`。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -229,13 +255,15 @@
|
||||
### 3.4 datetime
|
||||
|
||||
手动填写的日期/时间字段。系统时间用 `created_at` / `updated_at`。
|
||||
支持 `default_value`:静态时间字符串,或 `{ "$slot": "record_created_time" }`。`datetime + record_created_time` 是自动填充可编辑单元格;`created_at` 是只读创建时间元信息。
|
||||
|
||||
最小写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "datetime",
|
||||
"name": "截止时间"
|
||||
"name": "截止时间",
|
||||
"default_value": "2026-03-24 10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -251,7 +279,8 @@
|
||||
{
|
||||
"type": "datetime",
|
||||
"name": "截止时间",
|
||||
"style": { "format": "yyyy-MM-dd HH:mm" }
|
||||
"style": { "format": "yyyy-MM-dd HH:mm" },
|
||||
"default_value": { "$slot": "record_created_time" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -276,12 +305,19 @@
|
||||
### 3.6 user / group_chat
|
||||
|
||||
人员字段和群字段都支持 `multiple`。
|
||||
`user` 支持 `default_value`:人员 CellValue 数组,元素可用 `{ "id": "ou_xxx" }` 或 `{ "$slot": "current_user" }`;不要猜用户 ID。`group_chat` 不支持默认值。
|
||||
|
||||
默认值 / 约束:
|
||||
- `multiple` 默认 `true`
|
||||
- `user` 字段支持 `default_value` 配置,`group_chat` 字段不支持 `default_value` 配置。
|
||||
|
||||
```json
|
||||
{ "type": "user", "name": "负责人", "multiple": true }
|
||||
{
|
||||
"type": "user",
|
||||
"name": "负责人",
|
||||
"multiple": true,
|
||||
"default_value": [{ "$slot": "current_user" }, { "id": "ou_xxx" }]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -488,3 +524,4 @@ Object(对象字段)、Button(按钮字段)、Stage(流程字段)暂
|
||||
- `number` 的精度、货币、进度、评分配置都放在 `style` 下,不要写顶层 `precision`。
|
||||
- `datetime` 是手动日期字段;系统时间请改用 `created_at` / `updated_at`。
|
||||
- `formula` / `lookup` 没读 guide 前不要直接写。
|
||||
- 只有 `text`、`number`、静态 `select`、`datetime`、`user` 支持 `default_value`;清空统一传 `"default_value": null`。其他字段类型不要配置默认值。
|
||||
|
||||
@@ -11,14 +11,14 @@ lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Doing"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
|
||||
--yes
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -47,6 +47,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
- 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name`、`type`,并补齐该类型所需关键配置。
|
||||
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
|
||||
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;传 `null` 清空,省略表示不修改现有默认值。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
|
||||
- `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
@@ -59,6 +60,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
"name": "状态",
|
||||
"type": "select",
|
||||
"multiple": false,
|
||||
"default_value": ["Doing"],
|
||||
"options": [
|
||||
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "Doing", "hue": "Orange", "lightness": "Light" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-event
|
||||
version: 1.0.0
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Approval status changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -147,6 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
|
||||
| Topic | Reference | Coverage |
|
||||
|------------|------------------------------------------------------------------------------|---|
|
||||
| Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference |
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
||||
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
|
||||
170
skills/lark-event/references/lark-event-approval.md
Normal file
170
skills/lark-event/references/lark-event-approval.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Approval Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (2)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `approval.instance.status_changed_v4` | An approval instance status changed |
|
||||
| `approval.task.status_changed_v4` | An approval task status changed |
|
||||
|
||||
Both keys use a **Custom schema**. The raw Lark schema 2.0 envelope is flattened: event metadata is exposed as `type`, `event_id`, and `timestamp`, while approval business fields are exposed at the top level.
|
||||
|
||||
Both keys carry a **PreConsume hook** that subscribes the current authorized user through the Approval subscription APIs before listening. The consumer intentionally does **not** unsubscribe on exit; the server-side Approval subscription relation remains until it is canceled outside `event consume`. These keys require `--as user`.
|
||||
|
||||
## Listener and subscription selection
|
||||
|
||||
At the raw CLI level, each `event consume` process accepts exactly one EventKey. `approval.instance.status_changed_v4` and `approval.task.status_changed_v4` have different output shapes, so listening to both still means two processes.
|
||||
|
||||
For Approval only, `subscription_type` is an optional setup param used by PreConsume to register server-side Approval subscription relations before the local listener starts. It is **not** an output field, a local event filter, or a local subscription identity. The pushed event does not say which subscription relation caused delivery, and one business event can match both relations; deduplicate with `event_id` when needed.
|
||||
|
||||
`subscription_type` may be omitted, a single value, a comma-separated list, or a JSON string array:
|
||||
|
||||
```bash
|
||||
# Omitted: register both INVOLVED_APPROVAL and MANAGED_APPROVAL for this EventKey
|
||||
lark-cli event consume approval.instance.status_changed_v4 --as user
|
||||
|
||||
# Single relation
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Explicit multi-relation registration for one local consumer
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# JSON array form; quote it for the shell
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p 'subscription_type=["INVOLVED_APPROVAL","MANAGED_APPROVAL"]' \
|
||||
--as user
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `INVOLVED_APPROVAL` | Receive events where the current user is the approval requester or approver |
|
||||
| `MANAGED_APPROVAL` | Receive events under approval definitions managed by the current user |
|
||||
|
||||
User-intent inference:
|
||||
|
||||
| User intent | EventKey(s) | `subscription_type` |
|
||||
|---|---|---|
|
||||
| Mentions approval instances, approval forms, approval order/status, or "instance status" | `approval.instance.status_changed_v4` | infer from relation words below |
|
||||
| Mentions approval tasks, approval todo items, approver operations, or "task status" | `approval.task.status_changed_v4` | infer from relation words below |
|
||||
| Says "approval status changes/events" without saying task vs instance | both EventKeys | infer from relation words below |
|
||||
| Says "my approvals", "approvals involving me", "I requested/approved", "待我审批", "我发起/我参与" | requested EventKey(s) | `INVOLVED_APPROVAL` |
|
||||
| Says "approvals I manage", "managed definitions", "definitions managed by me", "我管理的审批定义" | requested EventKey(s) | `MANAGED_APPROVAL` |
|
||||
| Explicitly asks for both involved and managed, or says "all approval subscriptions" | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type`, or pass both values in one `-p` |
|
||||
| Relation is ambiguous and the user wants broad coverage | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type` so PreConsume registers both |
|
||||
|
||||
If the user's wording omits the relation and broad listening is acceptable, omit `subscription_type`. Ask only when registering both relations would be materially harmful.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `approval.instance.status_changed_v4` | `approval:instance:read` | user |
|
||||
| `approval.task.status_changed_v4` | `approval:task:read` | user |
|
||||
|
||||
## Subscription behavior
|
||||
|
||||
Startup calls the endpoint for the selected EventKey:
|
||||
|
||||
```text
|
||||
POST /open-apis/approval/v4/instances/subscription
|
||||
POST /open-apis/approval/v4/tasks/subscription
|
||||
```
|
||||
|
||||
For each resolved `subscription_type`, PreConsume sends one request body:
|
||||
|
||||
```json
|
||||
{"subscription_type":"INVOLVED_APPROVAL"}
|
||||
```
|
||||
|
||||
If `subscription_type` is omitted, PreConsume sends two registration requests for that EventKey: one with `INVOLVED_APPROVAL`, then one with `MANAGED_APPROVAL`. If listening to both instance and task events, run two consumers; each consumer may omit `subscription_type` to register both relations for its own EventKey.
|
||||
|
||||
Do not start two consumers for the same Approval EventKey merely to split `INVOLVED_APPROVAL` and `MANAGED_APPROVAL`. The server push and flattened output are keyed by EventKey and cannot be distinguished by subscription relation.
|
||||
|
||||
Shutdown behavior:
|
||||
|
||||
`event consume` does not call the Approval unsubscribe APIs when it exits. This applies to graceful exit, Ctrl+C / SIGTERM, stdin EOF, `--timeout`, and `--max-events`.
|
||||
|
||||
To stop future delivery for a user, cancel the Approval subscription relation outside this consumer. The unsubscribe APIs are separate operations and are not called by `event consume`.
|
||||
|
||||
## Output fields
|
||||
|
||||
Common fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type |
|
||||
| `event_id` | string | Globally unique event ID; use for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time in milliseconds, taken from `header.create_time` |
|
||||
|
||||
Instance event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `external_id` | string | Third-party approval instance id, when present |
|
||||
| `status` | string enum | `PENDING`, `APPROVED`, `REJECTED`, `CANCELED`, `DELETED`, `REVERTED`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
| `start_user` | object | Instance starter user IDs, omitted when unavailable |
|
||||
| `start_user.open_id` | string (open_id) | Instance starter open_id, when present |
|
||||
| `start_user.union_id` | string (union_id) | Instance starter union_id, when present |
|
||||
| `start_user.user_id` | string (user_id) | Instance starter tenant user_id, when present |
|
||||
|
||||
Task event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `task_id` | string | Approval task id |
|
||||
| `external_id` | string | Third-party approval external id, when present |
|
||||
| `task_external_id` | string | Third-party task external id, when emitted |
|
||||
| `assigned_user` | object | Task assignee or operator user IDs, omitted for automatic flows without an operator |
|
||||
| `assigned_user.open_id` | string (open_id) | Task assignee or operator open_id, when present |
|
||||
| `assigned_user.union_id` | string (union_id) | Task assignee or operator union_id, when present |
|
||||
| `assigned_user.user_id` | string (user_id) | Task assignee or operator tenant user_id, when present |
|
||||
| `status` | string enum | `REVERTED`, `PENDING`, `APPROVED`, `REJECTED`, `TRANSFERRED`, `ROLLBACK`, `DONE`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Stream approval instance updates broadly; registers both involved and managed relations
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user
|
||||
|
||||
# Stream approval instance updates only for approvals involving the current user
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Stream approval task updates for definitions managed by the current user
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Broad approval status listening:
|
||||
# run both EventKeys as separate processes; omit subscription_type so each registers both relations.
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user > approval-instance.ndjson &
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
--as user > approval-task.ndjson &
|
||||
wait
|
||||
|
||||
# Listen to both involved and managed task subscriptions with one local consumer.
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user > approval-task.ndjson
|
||||
|
||||
# Project a compact approval-task record
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user \
|
||||
--jq '{event_id, task_id, status, at: .operate_time}'
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 3.0.2
|
||||
version: 3.1.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -15,13 +15,7 @@ metadata:
|
||||
|
||||
## 术语约定
|
||||
|
||||
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
|
||||
|
||||
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
|
||||
| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 |
|
||||
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
|
||||
同一对象的交替说法,按此映射解析用户口语:**工作表(sheet)**= 子表 / tab / 标签页(`sheet_id` 是稳定标识);**电子表格(spreadsheet)**= 工作簿 / 表格(顶层容器,由 `--url` 或 `--spreadsheet-token` 定位);**reference_id** = 表内对象的稳定标识,即各对象主键 flag 接受的值(与 `--image-uri` 图片上传句柄不是一回事)。
|
||||
|
||||
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
|
||||
|
||||
@@ -34,32 +28,33 @@ metadata:
|
||||
|
||||
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
|
||||
|
||||
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
|
||||
下列准则横切所有任务,**动手前先过一遍**——被索引直接路由进某个工具参考也一律生效;展开与边界见括注的 reference。
|
||||
|
||||
1. **最小改动**:除任务要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet,**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
|
||||
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
|
||||
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值;**凡可由表内其它单元格推导的派生值默认就用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`,而且**只要公式真实写入表格,收尾默认就要继续跑 `lark-sheets-formula-verify` 的 `+formula-verify`,直到 `status='success'`**。
|
||||
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 提取 / 查找)一律写公式而非静态值——**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动"**;写公式前先读 `lark-sheets-formula-translation`,**公式落表后收尾必跑 `+formula-verify` 直到 `status='success'`**。
|
||||
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
|
||||
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
|
||||
6. **多步写入分流**:美化收尾(样式 / 合并 / 行高列宽 / 冻结的任意组合)→ 一次 `+styles-put` 声明式规格交付(见 `lark-sheets-styles-put`);**同一个写操作**打多个区域 → 用该命令自身的复数形态(`--ranges` / map 入参);只有**跨类型的原子操作链**(如插列 → 写表头 → 回填数据)才用 `+batch-update`(high-risk-write,**调用必带 `--yes`**,fail-fast 不回滚;语义见 `lark-sheets-batch-update`)。
|
||||
7. **分组汇总用透视表**:"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
|
||||
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
|
||||
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
|
||||
10. **缺失值不编造**:补齐 / 扩展 / 按原表格式续填时,查不到或无法确定的值一律留空 + 备注注明("暂未发布 / 未知 / 待核实"),禁止用推算值 / 估算值 / 凭空数据充数;原表若已示范缺失值写法(空值 + 备注),照抄该约定。宁可留空标注,不填不可靠的数。
|
||||
|
||||
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流为:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
> 端到端工作流:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证;实操展开见下方「执行要点」。
|
||||
|
||||
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
|
||||
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**:命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则。
|
||||
把高频意图映射到**真实存在**的 shortcut / flag(agent 常从 Excel / Google Sheets / OpenAPI 误迁移命令名)。**选定命令后先读「动手前读」列指向的 reference 再动手**——命令名对得上不代表用法对。
|
||||
|
||||
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(`--range` 可省略 = 读整个子表,无需先探行列;限定范围才传) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并),不必事后再刷;payload 里不存在的 sheet 名会自动建子表,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力;常见借口见下方 ⚠️) |
|
||||
| 写纯文本值(整块 CSV 平铺;列里没有需字面保真的编号 / 点分日期) | `+csv-put`(定位用 `--start-cell` 左上角锚点格,也接受 `--range` 别名) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`),改用 `+table-put` 声明 `dtypes:object` |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期等**量值**——不看当下要不要排序求和,量值一律走这里) | `+table-put --sheets '{"sheets":[{"name":…,"columns":[…],"dtypes":{…},"formats":{…},"data":[[…]]}]}'`(不存在的 sheet 名自动建子表;同时美化加 `--styles` 一步带样式,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(落成文本、丢计算能力,见下方 ⚠️) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook) | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可;**公式落表后继续 `+formula-verify` 收尾**) | `lark-sheets-write-cells` | — |
|
||||
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(单区域 `--range`+`--cells`;**散布多处 / 跨表用 `--writes` 一次原子交付**,每项自带 sheet_name;公式落表后继续 `+formula-verify` 收尾) | `lark-sheets-write-cells` | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | `lark-sheets-float-image` | — |
|
||||
| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find`、`+find`、`--query` |
|
||||
@@ -68,37 +63,53 @@ metadata:
|
||||
| 复核某次(AI)编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20) | `lark-sheets-changeset` | — |
|
||||
| 取当前文档 revision(版本号) | `+revision-get` | `lark-sheets-workbook` | — |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表应走 `+sheet-copy` / `+sheet-create` |
|
||||
| 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id` → `+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再用 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `lark-sheets-batch-update` | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag) |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(绕路)、本地读 .xlsx 再 `+workbook-create` 重灌(多此一举)、想并入**已有工作簿**却用它(import 只会另起新表,加子表走 `+sheet-copy` / `+sheet-create`) |
|
||||
| 参考某个**已有在线表**、把多份数据各作为一张子表**追加**进去 | 先 `+workbook-info` → `+sheet-copy` 复制模板子表(公式 / 合并 / 底色 / 列宽全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` + `+table-put --sheets/--styles` | `lark-sheets-workbook` | `+workbook-import` / `+workbook-create` 另起独立新表(这两条只产新表、不接受已有表定位) |
|
||||
| **已有**表美化收尾(样式 / 边框 / 合并 / 行高列宽 / 冻结的任意组合,单表或多表) | `+styles-put --styles '{"styles":[{"name":…,"cell_styles":[…],"cell_merges":[…],"row_sizes":[…],"col_sizes":[…],"freeze":{…}}]}'`(一份规格一次原子交付,词汇同 `+table-put --styles`) | `lark-sheets-styles-put` | 拼 `+batch-update` 的 `--operations` 子操作数组做美化、逐区域多次 `+cells-set-style` |
|
||||
| 清除内容 / 格式 | `+cells-clear --yes`(需确认;范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear --yes`(需确认;`--scope`) | `lark-sheets-batch-update` | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令;连同样式一起调时并入 `+styles-put` 的 `row_sizes` / `col_sizes`) | `lark-sheets-range-operations` | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
|
||||
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create`(先 `+chart-create --print-example <column\|bar\|line\|pie\|combo…>` 本地拿最小可用 `--properties` 模板,改 refs / index 即可用) | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
|
||||
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight`、`+conditional-format`、逐格 `+cells-set-style` 硬凑 |
|
||||
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create`) |
|
||||
|
||||
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`;只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错),**写完后再读 `lark-sheets-formula-verify` 并执行 `+formula-verify` 收尾**。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过。
|
||||
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入,常规二维表用 `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。
|
||||
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 能在写数据的**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里若 sheet 名不在工作簿中会自动新建子表——**纯文本表要新建子表 + 美化时同样走这里**(`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式」少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:动作里**含样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 配色 / 列宽行高)→ 先读 `lark-sheets-visual-standards`;**要写飞书公式** → 先读 `lark-sheets-formula-translation`,写完跑 `+formula-verify` 收尾(见 `lark-sheets-formula-verify`)。主任务是建表 / 录入也一样适用。
|
||||
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行走**(凭证 / 证件照 / 每行配图)→ `+cells-set-image`;自由摆放的装饰(logo / 水印 / 封面)→ `+float-image-create`。别因「浮动图更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 日期 / 计数等**量值**一律数值写入——常规二维表用 `+table-put`(`dtypes` + `formats`),宽表 / 合并表头版式用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`。只有编号 / 身份证等**标识符**才 `+csv-put` 平铺。"只是展示不用算 / 样式以后再刷"不构成把量值写成字符串的理由——类型不能后补。判据见 `lark-sheets-write-cells`「数字还是文本」。
|
||||
> ⚠️ **要新建子表 / 整表美化 → 别「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(底色 / 边框 / 列宽行高 / 合并 / 冻结),payload 里不存在的 sheet 名自动建子表,纯文本表同样适用;比事后多次刷样式少好几次调用。存量表事后美化则一次 `+styles-put` 交付(同一份 `--styles` 词汇)。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 用 `--start-cell`(也接受 `--range` 别名,区间取左上角)。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`(无 `--with-styles` 这类 flag);**看合并单元格**用 `+sheet-info` 的 `merged_cells`。
|
||||
|
||||
💡 **高频写命令签名(照抄改参即可;各命令 `--help` 的 Tips 段有同款示例)**:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +cells-set --url <U> --sheet-name S1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]' # --cells 恒为二维数组 [[…]],单格也是 [[{…}]]
|
||||
lark-cli sheets +cells-set-style --url <U> --sheet-name S1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center
|
||||
lark-cli sheets +styles-put --url <U> --styles - <<'JSON'
|
||||
{"styles":[{"name":"S1","cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#F0F0F0"}],"col_sizes":[{"range":"A:D","type":"pixel","size":120}],"freeze":{"rows":1}}]}
|
||||
JSON
|
||||
lark-cli sheets +batch-update --url <U> --yes --operations - <<'JSON'
|
||||
[{"shortcut":"+cells-set","input":{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}}]
|
||||
JSON
|
||||
lark-cli sheets +dim-freeze --url <U> --sheet-name S1 --dimension row --count 2
|
||||
lark-cli sheets +dim-insert --url <U> --sheet-name S1 --position 3 --count 2 --inherit-style before # 行/列由 --position 决定:数字=行、字母=列,无 --dimension
|
||||
lark-cli sheets +cols-resize --url <U> --sheet-name S1 --range A:C --width 120 # 像素;分列不同宽用 --widths '{"A":80,"C:E":120}'
|
||||
lark-cli sheets +sheet-copy --url <U> --sheet-name 源表名 --title 副本名 # --sheet-name=源表、--title=新表名
|
||||
```
|
||||
|
||||
## 执行要点(读取 / 原生工具 / 陷阱)
|
||||
|
||||
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
|
||||
### 读取:按需求选路径(细则见 `lark-sheets-read-data`)
|
||||
|
||||
| 用户需求 | 读取路径 |
|
||||
|---|---|
|
||||
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) |
|
||||
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
|
||||
| "完善 / 补齐 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行) |
|
||||
| "查一下 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
|
||||
| 需要公式 / 样式 / 批注 | `+cells-get` |
|
||||
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5) |
|
||||
|
||||
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。
|
||||
> "补齐 / 填空"类只探前 10 行就写会漏写表尾——先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。
|
||||
|
||||
### 计算:原生工具优先,代码兜底(强化准则 7)
|
||||
|
||||
@@ -122,16 +133,16 @@ metadata:
|
||||
|
||||
### 易漏陷阱
|
||||
|
||||
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行查 `#VALUE!` / `#REF!` / `#DIV/0!`,然后继续跑 `+formula-verify` 直到 `status='success'`;同一方案试错上限 3 次。
|
||||
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 转换公式用 `IFERROR` 包裹;写完查首末各 5 行错误码,再跑 `+formula-verify` 到 `status='success'`;同一方案试错上限 3 次。
|
||||
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
|
||||
- **隐藏行列**:`+csv-get` 默认含隐藏行列;设 `--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
|
||||
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`。
|
||||
- **隐藏行列**:`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,先 `+workbook-info` 掌握全局。
|
||||
- **NLP 任务分批**:语义理解 / 翻译 / 打标用 NLP 处理(代码只做分批 / 行号映射 / 写回);大数据量分批(约 30 行 / 批)即时写回,多批用 `+batch-update`。
|
||||
|
||||
## References
|
||||
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
|
||||
reference 分两组:先读**通用方法与规范**(横切所有任务的样式 / 公式规则),再按操作对象进入**工具参考**查具体 shortcut。编辑类任务务必先过通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
|
||||
|
||||
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
|
||||
|
||||
@@ -151,6 +162,7 @@ metadata:
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。只要这次写入真实落了公式,收尾默认继续执行 `lark-sheets-formula-verify`。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet Styles Put](references/lark-sheets-styles-put.md) | 把一份声明式视觉规格(样式/边框/合并/行高列宽/冻结)一次性应用到已有飞书表格的多个子表,整份规格原子提交。当任务是对存量表做美化收尾、批量刷样式、统一版式时使用。样式取值标准见 lark-sheets-visual-standards;建新表带样式走 lark-sheets-workbook(+workbook-create --styles)、写数据同步带样式走 lark-sheets-write-cells(+table-put --styles),三者共用同一份 --styles 词汇。仅针对飞书表格。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
|
||||
@@ -164,42 +176,22 @@ metadata:
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如:
|
||||
|
||||
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run`
|
||||
- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
|
||||
|
||||
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
|
||||
各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 `_公共四件套 · 系统:--dry-run_`;`_公共:URL/token(无 sheet 定位)…_` 表示只接 URL/token)。type / 必填 / 描述在本段统一声明:
|
||||
|
||||
### 公共 flag(定位资源)
|
||||
|
||||
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"):
|
||||
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 解析 `/sheets/`、`/spreadsheets/` 与 `/wiki/` 三种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- **`/wiki/` 知识库链接可直接传 `--url`**:会自动定位到链接背后的电子表格;若该链接背后不是电子表格(而是文档 / 多维表格等),则报错。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
- ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:整段用单引号包裹**,如 `--range 'Sheet1!A1:B2'`——单引号能挡住 bash 的 history expansion(`!` 被拦成 `event not found`;双引号挡不住;别改用 `set +H`,原因见下方「复合 JSON / 大入参」)。sheet 名含特殊字符(`-` / 空格 / 非 ASCII)需在内部按 A1 标准再包一层单引号时,用 `'\''` 转义保持外层单引号,如 `--source ''\''Sales-2025'\''!A1:D100'`。
|
||||
- **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet 或 wiki URL |
|
||||
| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token |
|
||||
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id |
|
||||
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 |
|
||||
|
||||
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
|
||||
1. **spreadsheet 定位(必填)**:`--url`(解析 `/sheets/`、`/spreadsheets/`、`/wiki/` 三种链接;wiki 链接自动定位背后的电子表格)与 `--spreadsheet-token`(裸 token)二选一。**例外**:`+workbook-create` / `+workbook-import` 产出**还不存在**的表,不接受任何定位 flag。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一。
|
||||
- ⚠️ **不确定 sheet 名时禁止猜 `Sheet1`**:除非对话或上下文已出现具体值,第一步先 `+workbook-info` 拿 `sheets[].sheet_id/title` 再选——中文表的子表常叫"数据"/"工作表 1"/业务名,猜名大概率撞 `sheet not found`。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:仍必须传 `--sheet-id` / `--sheet-name`。
|
||||
- ⚠️ **A1 引用含 `!` 时整段用单引号包裹**(`--range 'Sheet1!A1:B2'`,挡 bash history expansion;别用 `set +H`,sh/dash 下非法)。sheet 名含 `-`/空格需内层再包单引号时用 `'\''` 转义:`--source ''\''Sales-2025'\''!A1:D100'`。
|
||||
- **例外**:徽章标 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(`+workbook-info` / `+workbook-export` / `+batch-update` / `+styles-put` / `+dropdown-update|delete` / `+cells-batch-clear` / `+sheet-create`)不接受 sheet 定位。`+pivot-create` 用 `--target-sheet-id/name`(XOR,可都不传)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
|
||||
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
|
||||
# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
|
||||
# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)
|
||||
lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
```
|
||||
|
||||
### 系统 flag
|
||||
@@ -208,27 +200,27 @@ lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
| --- | --- | --- | --- |
|
||||
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
|
||||
| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
|
||||
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name <name>` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
|
||||
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 |
|
||||
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起调用、不需要其它 required flag。搭配 `--flag-name` 指定查哪个 flag;省略时列出该 shortcut 可查询的 flag。仅对含复合 JSON flag 的 shortcut 有效。 |
|
||||
| `--flag-name` | string | 否 | 配合 `--print-schema`:flag 名不带 `--` 前缀(`cells` / `properties`)。**支持点分路径切片**:`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schema(chart 的 properties 约 1700 行)按需取,别整篇翻页。 |
|
||||
|
||||
**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。
|
||||
> ⚠️ **high-risk-write 命令清单(首次调用就带 `--yes`,别等 exit 10 再补;或先 `--dry-run` 预览)**:`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`,以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`。
|
||||
|
||||
**Agent 使用提示**:写复合 JSON flag 前对结构不确定时,先 `--print-schema --flag-name <name>`(深层字段用点分路径切片)再构造 payload;图表直接 `+chart-create --print-example <type>` 拿最小可用模板改参。reference 的 `## Schemas` 段只给一层结构。
|
||||
|
||||
### flag 内容类型与输出约定(术语速记)
|
||||
|
||||
- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。
|
||||
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。
|
||||
- JSON 类入参分三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 可查);**简单 JSON** = 一二维标量数组;**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对复合 JSON flag 有效。
|
||||
- **envelope**:所有 shortcut 返回统一外层 `{ok, identity, data, ...}`;写操作不会自动回读,校验自行调用 `+*-list` / `+*-get` / `+cells-get`。
|
||||
|
||||
## 复合 JSON / 大入参:优先 stdin
|
||||
|
||||
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。
|
||||
|
||||
推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
|
||||
大 payload(`--operations` / `--cells` / `--sheets` / `--styles` / `--properties`…)、或含换行 / 引号 / `!` 等特殊字符时,优先 heredoc stdin(`-`)传入,避免命令行超长与 shell 转义问题:
|
||||
|
||||
```bash
|
||||
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
|
||||
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
|
||||
lark-cli sheets +batch-update --url "..." --yes --operations - <<'JSON'
|
||||
[{"shortcut":"+cells-set","input":{...}}]
|
||||
JSON
|
||||
```
|
||||
|
||||
**参数含特殊字符(`!` / 引号 / 空格 / 非 ASCII)时,用单引号包裹该参数即可,不要起手 `set +H` 之类的 shell 开关来防转义。** `set +H`(关 bash history expansion)在 `sh` / `dash` 下是非法选项(`set: Illegal option -H`)、会让整条命令直接失败;而单引号挡得住 `!` 的 history expansion(否则报 `event not found`),对 bash 与 `sh` / `dash` 一致安全。参数本身含单引号、或 payload 较大时,按上文走 stdin。
|
||||
|
||||
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`--<flag> - < 文件`)。
|
||||
- **stdin 每次调用只能给一个 flag**:`+table-put` 同时传 `--sheets` 与 `--styles` 两个大 JSON 时,一个走 `-`、另一个走 `@./styles.json`(`@file` 只接受 cwd 下相对路径,**绝对路径会被拒**;正解是 stdin,别 cd、别把临时文件写进用户项目目录)。
|
||||
- **参数含特殊字符时用单引号包裹即可,不要 `set +H`**(sh/dash 下非法直接报错);参数本身含单引号或 payload 大时走 stdin。
|
||||
|
||||
@@ -8,22 +8,19 @@
|
||||
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。
|
||||
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
|
||||
|
||||
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 的原子提交只保证“写入动作都执行了”,不保证整批公式运行结果 zero-error。
|
||||
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 只保证"写入动作按序执行了",不保证整批公式运行结果 zero-error。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。
|
||||
写入。把**跨类型、有顺序依赖**的多个写入操作合并为一次请求按序执行(如插列 → 写表头 → 回填数据)。注意:不支持嵌套 `+batch-update`。
|
||||
|
||||
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
|
||||
**先分流再动手(按操作组合选入口)**:美化收尾(样式 / 合并 / 行高列宽 / 冻结的任意组合)→ 一次 `+styles-put`(声明式规格,见 `lark-sheets-styles-put`),不要拼 `--operations` 子操作数组;**同一个写操作**打多个区域 → 用该命令自身的复数形态(`+cells-set --writes` / `+cells-batch-clear` / `+dim-delete --ranges` / resize 的 map 形态等);只有跨类型的原子操作链才用本命令。
|
||||
|
||||
**⚠️ 何时必须使用 `+batch-update`(硬性要求)**:
|
||||
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
|
||||
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`)
|
||||
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
|
||||
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+styles-put` / `+dropdown-update` / `+dropdown-delete` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
|
||||
|
||||
**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(如 `--widths '{"A":100,"C:E":120}'`,见 `lark-sheets-range-operations`),一次调用原子完成;map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
|
||||
**行高列宽批量不走这里**:多行 / 多列不同尺寸用 `+styles-put` 的 `row_sizes` / `col_sizes`(可与样式同批),或 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(见 `lark-sheets-range-operations`);map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
**执行语义(fail-fast,不回滚)**:默认首个失败的子操作即中断剩余操作,但**已执行成功的子操作不回滚**——服务端报 "N succeeded, M failed" 时前 N 个已实际生效。修复失败项后**只重发失败起的剩余子集**,整批重发会把已成功的操作(如插行)重复应用。传 `--continue-on-error` 则遇失败仍继续执行剩余操作。正因如此,含结构变更(插删行列 / 移动)的批次失败后要先回读确认现状再续发。
|
||||
|
||||
**公式相关批处理的默认闭环**:
|
||||
- 写前:先读 `lark-sheets-formula-translation`,把公式改写成飞书可执行语义。
|
||||
@@ -37,7 +34,6 @@
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+batch-update` | high-risk-write | 批量 |
|
||||
| `+cells-batch-set-style` | write | 批量 |
|
||||
| `+dropdown-update` | write | 对象 |
|
||||
| `+dropdown-delete` | high-risk-write | 对象 |
|
||||
| `+cells-batch-clear` | high-risk-write | 批量 |
|
||||
@@ -50,29 +46,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 |
|
||||
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认 fail-fast:首个失败即中断剩余操作,**已执行的子操作不回滚**(服务端报 "N succeeded, M failed" 时 N 个已生效,修复后只重发失败起的剩余子集,不要整批重发);传 --continue-on-error 遇失败仍继续;不支持嵌套;按数组顺序串行执行 |
|
||||
| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) |
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style |
|
||||
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
|
||||
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
|
||||
| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) |
|
||||
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
|
||||
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
|
||||
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
|
||||
| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) |
|
||||
| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) |
|
||||
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) |
|
||||
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) |
|
||||
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) |
|
||||
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON(结构同 +cells-set-style) |
|
||||
|
||||
### `+dropdown-update`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
@@ -115,16 +91,6 @@ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;
|
||||
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
|
||||
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 …
|
||||
|
||||
### `+cells-batch-set-style` `--border-styles`
|
||||
|
||||
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
|
||||
|
||||
**顶层字段**:
|
||||
- `top` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `bottom` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `left` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `right` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
|
||||
### `+dropdown-update` `--options`
|
||||
|
||||
_列表选项_
|
||||
@@ -169,17 +135,6 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
> ]
|
||||
> ```
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
多 range 应用同一组 style(服务端走 `+batch-update` 原子事务):
|
||||
|
||||
```bash
|
||||
# 表头行 + 汇总行同时刷成蓝底白字
|
||||
lark-cli sheets +cells-batch-set-style --url "..." \
|
||||
--ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \
|
||||
--background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold
|
||||
```
|
||||
|
||||
### `+cells-batch-clear`
|
||||
|
||||
多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`:
|
||||
@@ -195,6 +150,6 @@ lark-cli sheets +cells-batch-clear --url "..." \
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
|
||||
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。
|
||||
- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。
|
||||
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验,input 键必须在该 shortcut 的 flag 词汇表内(未知键报错并提示最近似键与完整键契约);**校验错误聚合上报**——所有子操作的首错一次性返回,全部修完再重发一次即可;**禁止嵌套 `+batch-update`**。`+cells-batch-clear` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
|
||||
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板,不发起调用。
|
||||
- `Execute`:按声明顺序串行执行;默认 fail-fast——任一子操作失败即中断剩余操作,**已成功的子操作不回滚**,报错会注明已生效数量与「仅重发失败起的剩余子集」的续发方式。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
|
||||
|
||||
**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。
|
||||
**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。**构造起点优先用 `lark-cli sheets +chart-create --print-example <column|bar|line|area|pie|scatter|radar|combo>` 拿最小可用模板改参**(本地即时返回);查深层字段用点分路径切片 `--print-schema --flag-name properties.snapshot.plotArea.axes`,别整篇 dump 翻页。完整结构以 `--print-schema --flag-name properties` 为准。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
注意:
|
||||
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
|
||||
- `+cells-get` 可独立控制隐藏与筛选:`--skip-hidden` 只控制隐藏或分组折叠的行列,`--skip-filter` 只控制被筛选掉的行。两者都为 `false` 时返回完整数据;仅 `--skip-filter=true` 时保留隐藏行列但跳过筛选行;仅 `--skip-hidden=true --skip-filter=false` 时跳过隐藏行列但保留筛选行;两者都为 `true` 时只返回同时可见且未被筛选掉的数据。为兼容旧调用,未显式传 `--skip-filter` 时会继承 `--skip-hidden`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间,用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+cells-get` 返回的 `row_indices` / `col_indices` 判断真实位置
|
||||
- 要判断单元格内容是否被行高列宽挤到显示不全(排版检查、调整行高列宽前),给 `+cells-get` 加 `--include truncation`:会按字号 / 自动换行 / 行高列宽估算并返回被截断单元格的 `isRowTruncated` / `isColTruncated`(未返回视为未截断)。有额外计算开销,仅需要时才开
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
|
||||
@@ -53,7 +54,7 @@
|
||||
- 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。
|
||||
- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get`
|
||||
- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入
|
||||
- **直接按 `+cells-get` 返回二维数组下标推导真实位置**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
|
||||
- **直接按 `+cells-get` 返回二维数组下标推导真实位置**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、`--skip-filter=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
|
||||
- **CSV 行号计数错误**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数
|
||||
- **手动数列确定列号**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母
|
||||
- **用数据列的值推导行号(常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑
|
||||
@@ -99,9 +100,11 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个。`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `truncation`) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏或分组折叠的行列。默认 `false`;未显式传 `--skip-filter` 时,筛选行也会随之跳过(兼容旧行为) |
|
||||
| `--skip-filter` | bool | optional | 跳过被筛选掉的行。未设置时继承 `--skip-hidden`;显式设为 `false` 可在跳过隐藏行列的同时保留筛选行 |
|
||||
|
||||
### `+dropdown-get`
|
||||
|
||||
@@ -117,8 +120,9 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--range` | string | optional | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet)。**可省略:缺省读取整个子表**(按表格实际边界裁剪,返回的 actual_range 标注实际读取范围);大表配合 --max-chars / --output-path 控制体量 |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
|
||||
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
|
||||
@@ -131,6 +135,8 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 |
|
||||
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
|
||||
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range(会跨过表中部的整行空行 / 整列空列,不会被截断) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。底层工具即使不传也有约 50000 的默认截断,故此处显式发送以放宽;要整表无截断请用 --output-path 落盘(自动放开为无限)。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
|
||||
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
|
||||
|
||||
## Examples
|
||||
@@ -147,6 +153,10 @@ lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet
|
||||
|
||||
# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id)
|
||||
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30"
|
||||
|
||||
# 全量读:省略 --range 即读整个子表(按实际边界裁剪,返回 actual_range 标注实读范围),
|
||||
# 无需先 +workbook-info 探行列再拼 range;大表配合 --max-chars / --output-path
|
||||
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
|
||||
```
|
||||
|
||||
输出契约(envelope.data):
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义
|
||||
- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容
|
||||
- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`)
|
||||
- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before` 继承前一行/列 / `after` 继承后一行/列);它只决定继承哪一侧的样式,**插入位置始终在 `--position` 之前,不改变插入方向**。⚠️ 不传时默认继承**后一行/列**(同 `after`);底层无法插入"无格式"行/列,要真正的纯空白行/列,插入后再用 `+cells-clear --scope formats` 清除新行/列的格式。
|
||||
- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21)
|
||||
|
||||
**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**:
|
||||
@@ -40,7 +40,7 @@
|
||||
- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
|
||||
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
|
||||
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容(`+csv-get` / `+cells-get` 见 `lark-sheets-read-data`)
|
||||
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`
|
||||
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`。不要把 `--inherit-style after` 当成“插到 D 列右侧”,它不是插入方向参数。
|
||||
- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
|
||||
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
|
||||
- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1
|
||||
@@ -76,7 +76,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) |
|
||||
| `--inherit-style` | string | optional | 新行/列样式继承 enum:`before`(继承前一行/列)/ `after`(继承后一行/列);不传时默认继承后一行/列(同 `after`),底层无法插入无格式行/列。只决定继承哪侧样式、不改变插入方向(始终插在 `--position` 之前);要纯空白行/列请插入后用 `+cells-clear --scope formats`(可选值:`before` / `after`) |
|
||||
| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` |
|
||||
| `--count` | int | required | 插入数量(>0) |
|
||||
|
||||
@@ -86,7 +86,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` |
|
||||
| `--range` | string | xor | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C`。与 `--ranges` 二选一 |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | xor | 要删除的多个行/列区间 JSON 数组(最多 100 个,如 `["5:5","8:8","11:13"]` 或 `["C:C","F:G"]`),全行或全列不可混用,区间不可重叠;与 `--range` 二选一。CLI 按位置**从大到小逆序**合成一次原子批量删除——正序删除会因前面的行/列被删导致后续索引前移错位,逆序由 CLI 代劳,无需自行排序 |
|
||||
|
||||
### `+dim-hide`
|
||||
|
||||
@@ -168,6 +169,11 @@ lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes
|
||||
|
||||
# 删除 D-F 列
|
||||
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes
|
||||
|
||||
# 删除多个散布区间(如按查重结果删行):--ranges 一次原子交付。
|
||||
# CLI 自动按位置从大到小逆序执行——正序会因前面的行被删导致后续索引前移错位;
|
||||
# 无需自行排序,也不要为此拼 +batch-update 的子操作数组
|
||||
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --ranges '["5:5","8:8","11:13"]' --yes
|
||||
```
|
||||
|
||||
### `+dim-hide` / `+dim-unhide`
|
||||
@@ -207,6 +213,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`,`--range` 与 `--ranges` 二选一、`--ranges` 各区间同维度且不可重叠(≤100 个);`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。
|
||||
- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。
|
||||
|
||||
91
skills/lark-sheets/references/lark-sheets-styles-put.md
Normal file
91
skills/lark-sheets/references/lark-sheets-styles-put.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Lark Sheet Styles Put(+styles-put)
|
||||
|
||||
> **本文定位**:对**已有**表格做美化收尾的默认入口——样式 / 边框 / 合并 / 行高列宽 / 冻结写成一份声明式规格,一次调用原子交付。样式**取什么值**(配色 / 字号 / 对齐 / 数字格式标准)以 `lark-sheets-visual-standards` 为唯一权威,本文只讲**怎么落地**。
|
||||
>
|
||||
> **边界(三分流判定,按操作组合选入口)**:目标是**样式 / 合并 / 行高列宽 / 冻结**的任意组合 → 本命令;**同一个写操作**打多个区域(如多区域清除、批量下拉)→ 用该命令自身的复数形态(`--ranges` / map 入参);操作链**跨类型且有顺序依赖**(如插列 → 写表头 → 回填数据)→ `+batch-update`。美化收尾不需要也不应该拼 `--operations` 子操作数组。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。对存量表格的多个子表批量应用视觉规格:新表美化、加汇总行后统一版式、按分组合并同类单元格、调列宽行高、冻结表头。整份规格展开为一次原子批量提交,全部生效或全部不生效;纯样式盖章可安全重放(同一份规格重发无副作用)。
|
||||
|
||||
**词汇三处同构**:`--styles` 的字段词汇与 `+workbook-create --styles`(建新表同步美化)、`+table-put --styles`(写数据同步美化)完全一致——`cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 学一次三处通用。区别只有两点:本命令作用于**已有**表格(顶层 `--url` / `--spreadsheet-token` 定位),且 `cell_styles` 的 range 不受「本次写入区域」限制、可指向表内任意区域。
|
||||
|
||||
**规格要点**:
|
||||
|
||||
- 顶层 `{styles:[...]}`,每项对应一个目标子表,`name` 必须是真实子表名(不确定先 `+workbook-info` 查,禁止猜 `Sheet1`)。
|
||||
- 每个子表项按固定顺序执行:`cell_merges` → `cell_styles` → `row_sizes` → `col_sizes` → `freeze`;样式盖章允许覆盖含合并区的区域(合并区限制只针对值写入,样式不受限)。
|
||||
- `row_sizes` / `col_sizes` 只需 `{range, size}`(px,即像素尺寸;`standard` / 行的 `auto` 才需显式 `type`)。尺寸键统一是 `size`。
|
||||
- 加边框用 `border` 简写:`{"style":"solid","color":"#DDDDDD"}` 应用到四边;只有分侧不同样式才用 `border_styles` 完整形态。
|
||||
- `freeze` 用 `{rows:N, cols:N}` 冻结前 N 行 / 列,0 或省略表示该维度不冻结。
|
||||
|
||||
**回读校验**:整份规格执行成功后按编辑准则抽样回读受影响区域(`+cells-get --include style` 或 `+sheet-info` 看合并 / 行高列宽 / 冻结),确认关键样式实际生效。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+styles-put` | write | 批量 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+styles-put`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | required | 对**已有**表格应用的视觉规格 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表(`name` 用真实子表名),并至少给 `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 之一。字段词汇与 `+workbook-create` / `+table-put` 的 `--styles` 完全同构(cell_styles 用 A1 range + 扁平样式字段,边框用 `border` 简写 {style,weight,color} 四边同款、分侧才用 border_styles;row/col sizes 用行/列范围 + size(px 即像素,standard/auto 才需 type);merges 用单元格 range;freeze 用 `{rows:N, cols:N}` 冻结前 N 行/列)。整份规格展开为一次原子批量提交;range 不受「本次写入区域」限制,可指向表内任意区域 |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+styles-put` `--styles`
|
||||
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
### `+styles-put`
|
||||
|
||||
表头美化 + 按组合并 + 列宽 + 冻结首行,一次交付:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +styles-put --url "https://example.feishu.cn/sheets/shtXXX" --styles - <<'JSON'
|
||||
{"styles":[{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": [{"range":"A5:A8"},{"range":"A9:A12"}],
|
||||
"cell_styles": [
|
||||
{"range":"A1:F1","font_weight":"bold","background_color":"#1E5BC6","font_color":"#FFFFFF","horizontal_alignment":"center"},
|
||||
{"range":"A2:F30","border":{"style":"solid","color":"#DDDDDD"}}
|
||||
],
|
||||
"row_sizes": [{"range":"1:1","size":36}],
|
||||
"col_sizes": [{"range":"A:C","size":120}],
|
||||
"freeze": {"rows":1}
|
||||
}]}
|
||||
JSON
|
||||
```
|
||||
|
||||
多子表同一批交付(每个子表一个 styles 项):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +styles-put --url "..." --styles - <<'JSON'
|
||||
{"styles":[
|
||||
{"name":"明细","cell_styles":[{"range":"A1:H1","font_weight":"bold","background_color":"#F0F0F0"}],"freeze":{"rows":1}},
|
||||
{"name":"汇总","cell_styles":[{"range":"A1:D1","font_weight":"bold"}],"col_sizes":[{"range":"A:D","type":"pixel","size":140}]}
|
||||
]}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:`--styles` 必须是合法 JSON、`styles` 非空数组;每项 `name` 必填、至少给 `cell_merges` / `cell_styles` / `row_sizes` / `col_sizes` / `freeze` 之一;`cell_styles` 每项至少一个样式字段;展开后受子操作数(100)与总格数预算约束,超限报错给拆分建议。
|
||||
- `DryRun`:输出展开后每个子操作的请求模板,不发起调用。
|
||||
- `Execute`:整份规格合成一次批量请求按序执行;失败时报错会注明已生效的子操作区间与续发方式。
|
||||
@@ -197,10 +197,11 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
|
||||
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`--cells` 恒为二维数组(行 × 格),单格也是 `[[{"value":…}]]`;且行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
|
||||
> **单元格图片 vs 浮动图片(最易选错)**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
|
||||
|
||||
@@ -89,6 +89,19 @@
|
||||
|
||||
⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。
|
||||
|
||||
💡 **多个不连续区域写入(批量修公式的正解)**:散布多处(可跨 sheet)的值 / 公式写入,用 `--writes` 一次原子交付——每项 `{sheet_name, range, cells}`(sheet 定位必须写在每项里),不要为此拼 `+batch-update` 的 `--operations`,也不要逐区域多次调用(非原子):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +cells-set --url "..." --writes - <<'JSON'
|
||||
[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(明细!C:C)"}]]}
|
||||
]
|
||||
JSON
|
||||
```
|
||||
|
||||
范围级统一样式不在 `--writes` 里做(cells 逐格 `cell_styles` 仅用于逐格差异化),写完接 `+styles-put`。
|
||||
|
||||
💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。
|
||||
|
||||
💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步:
|
||||
@@ -102,7 +115,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
|
||||
```
|
||||
这比在 99 个单元格中都重复写样式 JSON 高效得多。
|
||||
|
||||
💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+cells-batch-set-style`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论:
|
||||
💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+styles-put`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论:
|
||||
- **可分层叠加**:对同一区域先刷字体色、再单独刷背景色、再单独刷边框,后一步不会清掉前一步——美化已有区域时无需一次带齐所有字段,可拆成多次窄调用。
|
||||
- **`border_styles` 按边合并**:只传 `{"top":{...}}` 只更新上边框,`bottom` / `left` / `right` 保留原状;不必为了「只改一条边」而把四边全部重传。(例外见上方「新增行的边框/样式禁止用 `{}` 跳过」:**全新行**底子里没有边框,仍需把要显示的边都显式传出。)
|
||||
|
||||
@@ -236,7 +249,7 @@ lark-cli sheets +dropdown-set \
|
||||
|
||||
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。
|
||||
>
|
||||
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
|
||||
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
|
||||
|
||||
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
|
||||
|
||||
@@ -259,8 +272,9 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | 写入区域(A1 格式) |
|
||||
| `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` |
|
||||
| `--range` | string | xor | 写入区域(A1 格式)。与 `--writes` 二选一(单区域用 --range+--cells,多区域用 --writes) |
|
||||
| `--cells` | string + File + Stdin(复合 JSON) | xor | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` |
|
||||
| `--writes` | string + File + Stdin(复合 JSON) | xor | 多区域写入 JSON 数组(最多 100 项),每项 `{sheet_name\|sheet_id, range, cells}`——**sheet 定位必须写在每项里**(与 +batch-update 子操作、+styles-put 项同惯例,不认顶层 --sheet-name),cells 结构同 `--cells`(二维数组,可逐格带 cell_styles/border_styles)。整批展开为**单次原子批量提交**,支持跨 sheet;典型场景:批量修复散布多处的公式、跨表同构写入——不要为此拼 +batch-update 的 --operations。与 `--range`+`--cells` 二选一;范围级统一样式不在此做,写完接 +styles-put |
|
||||
| `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 |
|
||||
| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
| `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 |
|
||||
@@ -346,6 +360,16 @@ _【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]]
|
||||
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
|
||||
- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array<string>, range?: string, operator?: enum, values?: array<oneOf>, …共 9 项 }
|
||||
|
||||
### `+cells-set` `--writes`
|
||||
|
||||
_多区域写入项数组(最多 100 项),整批单次原子提交;支持跨 sheet_
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `sheet_id` (string?) — 目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)
|
||||
- `sheet_name` (string?) — 目标子表名;与 sheet_id 二选一,必须写在每一项里
|
||||
- `range` (string) — A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)
|
||||
- `cells` (array) — 二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_…
|
||||
|
||||
### `+cells-set-style` `--border-styles`
|
||||
|
||||
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
|
||||
@@ -383,10 +407,11 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -401,7 +426,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) |
|
||||
| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) |
|
||||
| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(见 lark-sheets-batch-update) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(见 lark-sheets-batch-update) | 多次 `+cells-set-style`(非原子) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+styles-put`(cell_styles 多项即多区域,见 lark-sheets-styles-put) | 多次 `+cells-set-style`(非原子) |
|
||||
|
||||
### `+cells-set`
|
||||
|
||||
@@ -511,6 +536,8 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
|
||||
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
|
||||
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
|
||||
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
|
||||
# --sheets 与 --styles 都是大 JSON 时:stdin 每次调用只能给一个 flag,一个走 -、另一个走 @cwd 相对路径
|
||||
lark-cli sheets +table-put --url "<表URL>" --sheets - --styles @styles.json < sheets.json
|
||||
```
|
||||
|
||||
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。
|
||||
|
||||
@@ -1464,6 +1464,7 @@
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
表格元素, 用于展示结构化数据
|
||||
宽高分配规则参考 HTML table 的整体值 / 子项值处理逻辑, 但在 SXSD 中做确定化约束, 以保证跨端实现一致。
|
||||
边框规则:
|
||||
- 后设置优先:相邻单元格线条只有一个颜色, 如果均设置, 则右下单元格的设置覆盖左上单元格
|
||||
- 不设置边框属性时使用默认样式
|
||||
@@ -1476,6 +1477,8 @@
|
||||
- id: 表格唯一标识符(可选)
|
||||
- topLeftX/topLeftY: 左上角坐标
|
||||
- flipX/flipY: 水平/垂直翻转
|
||||
- width: 表格目标总宽度(可选)。若设置, 优先用于为未填写列宽的列分配剩余宽度; 当所有列宽均为空时, 所有列均分该值; 当所有列均已填写且该值大于已填写列宽总和时, 多出空间继续分配给现有列, 默认按各列当前宽度作为权重分配; 当已填写列宽之和超过或无法容纳该值时, 保留已填写列宽, 并以最终列宽总和回写 table.width
|
||||
- height: 表格目标总高度(可选)。若设置, 优先用于为未填写行高的行分配剩余高度; 当所有行高均为空时, 所有行均分该值; 当所有行均已填写且该值大于已填写行高总和时, 多出空间继续分配给现有行, 默认按各行当前高度作为权重分配; 当已填写行高之和超过或无法容纳该值时, 保留已填写行高, 并以最终行高总和回写 table.height
|
||||
table 子元素:
|
||||
- colgroup: 列组元素, 用于定义列的宽度
|
||||
- tr: 行元素, 包含多个单元格
|
||||
@@ -1484,10 +1487,10 @@
|
||||
- col: 列元素
|
||||
col 属性:
|
||||
- span: 列跨数, 默认为1, 可选
|
||||
- width: 列宽度, 默认值为110, 可选
|
||||
- width: 列宽度输入值, 默认值为110, 可选。无 table.width 时, 已填写列保持原值, 空列使用默认值; 有 table.width 时, 已填写列保持原值, 空列优先均分剩余宽度; 若不存在空列且 table.width 大于已填写列宽总和, 则多出空间按各列当前宽度作为权重分配到所有列; 若剩余宽度不足则空列回退为默认值, 并以最终列宽总和作为 table.width
|
||||
|
||||
tr 属性:
|
||||
- height: 行高, 默认为单元格高度
|
||||
- height: 行高输入值, 默认值为37, 可选。无 table.height 时, 已填写行保持原值, 空行使用默认值; 有 table.height 时, 已填写行保持原值, 空行优先均分剩余高度; 若不存在空行且 table.height 大于已填写行高总和, 则多出空间按各行当前高度作为权重分配到所有行; 若剩余高度不足则空行回退为默认值, 并以最终行高总和作为 table.height。若行高低于内容高度, 需要手动修改行高
|
||||
tr 子元素:
|
||||
- td: 单元格元素, 用于显示数据
|
||||
|
||||
@@ -1543,6 +1546,8 @@
|
||||
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
|
||||
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
|
||||
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
|
||||
<xs:attribute name="width" type="sml:PositiveSize" use="optional"/>
|
||||
<xs:attribute name="height" type="sml:PositiveSize" use="optional"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@
|
||||
- `<tr>` 内为 `<td>`
|
||||
- `<td>` 内可放 `<content>`
|
||||
|
||||
`<table>` 可选设置 `width` 和 `height`,分别表示表格的目标总宽度和总高度:
|
||||
|
||||
```xml
|
||||
<table topLeftX="80" topLeftY="120" width="800" height="300">
|
||||
<colgroup>
|
||||
<col width="240"/>
|
||||
<col/>
|
||||
</colgroup>
|
||||
<tr height="80">
|
||||
<td><content textType="body"><p>表头 1</p></content></td>
|
||||
<td><content textType="body"><p>表头 2</p></content></td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
### `<chart>`
|
||||
|
||||
图表元素必须至少包含:
|
||||
|
||||
@@ -38,6 +38,8 @@ SXSD_ATTR_ALIASES = {
|
||||
"fontColor": "color",
|
||||
}
|
||||
SERVER_FILLED_SXSD_ATTRS = {"id"}
|
||||
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
||||
DEFAULT_TABLE_ROW_HEIGHT = 37
|
||||
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
|
||||
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
|
||||
|
||||
@@ -88,6 +90,84 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
|
||||
def sum_sizes(sizes: list[int | float]) -> int | float:
|
||||
return sum(sizes)
|
||||
|
||||
|
||||
def is_filled_size(size: int | float | None) -> bool:
|
||||
return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
|
||||
|
||||
|
||||
def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
|
||||
if not sizes:
|
||||
return sizes
|
||||
final_sizes = [
|
||||
size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
|
||||
for index, size in enumerate(sizes)
|
||||
]
|
||||
remaining_size = target_size - sum_sizes(final_sizes[:-1])
|
||||
if remaining_size >= 1:
|
||||
final_sizes[-1] = remaining_size
|
||||
return final_sizes
|
||||
|
||||
size_to_redistribute = 1 - remaining_size
|
||||
for index in range(len(final_sizes) - 2, -1, -1):
|
||||
reduction = min(final_sizes[index] - 1, size_to_redistribute)
|
||||
final_sizes[index] -= reduction
|
||||
size_to_redistribute -= reduction
|
||||
if size_to_redistribute == 0:
|
||||
final_sizes[-1] = 1
|
||||
return final_sizes
|
||||
|
||||
final_sizes[-1] = 1
|
||||
return final_sizes
|
||||
|
||||
|
||||
def solve_weighted_min_layout(
|
||||
input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
|
||||
) -> dict[str, Any]:
|
||||
filled_indexes: list[int] = []
|
||||
empty_indexes: list[int] = []
|
||||
base_sizes: list[int | float] = []
|
||||
for index, size in enumerate(input_sizes):
|
||||
if is_filled_size(size):
|
||||
filled_indexes.append(index)
|
||||
base_sizes.append(size)
|
||||
else:
|
||||
empty_indexes.append(index)
|
||||
base_sizes.append(0)
|
||||
filled_sum = sum_sizes(base_sizes)
|
||||
|
||||
if target_min_size is None:
|
||||
final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
if not filled_indexes:
|
||||
average_size = target_min_size / len(input_sizes)
|
||||
final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
if empty_indexes:
|
||||
remaining_size = target_min_size - filled_sum
|
||||
final_sizes = [*base_sizes]
|
||||
if remaining_size > 0:
|
||||
average_size = remaining_size / len(empty_indexes)
|
||||
empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
|
||||
for index, empty_size in zip(empty_indexes, empty_sizes):
|
||||
final_sizes[index] = empty_size
|
||||
else:
|
||||
for index in empty_indexes:
|
||||
final_sizes[index] = default_size
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
|
||||
|
||||
ratio = max(1, target_min_size / filled_sum)
|
||||
actual_size = max(target_min_size, filled_sum)
|
||||
if ratio == 1:
|
||||
return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
|
||||
final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
|
||||
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
|
||||
|
||||
|
||||
def strip_xml(value: str) -> str:
|
||||
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
||||
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
||||
@@ -515,8 +595,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
|
||||
kind, attrs = match.group(1), match.group(2)
|
||||
content = ""
|
||||
if kind == "shape":
|
||||
close_index = slide_xml.find("</shape>", match.end())
|
||||
if kind in {"shape", "table"}:
|
||||
close_index = slide_xml.find(f"</{kind}>", match.end())
|
||||
if close_index != -1:
|
||||
content = slide_xml[match.end() : close_index]
|
||||
|
||||
@@ -525,6 +605,15 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
rotation = extract_numeric_attribute(attrs, "rotation") or 0
|
||||
table_layouts: dict[str, dict[str, Any] | None] = {}
|
||||
if kind == "table":
|
||||
width, table_layouts["width"] = resolve_table_dimension(
|
||||
content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
|
||||
)
|
||||
height, table_layouts["height"] = resolve_table_dimension(
|
||||
content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
|
||||
)
|
||||
if all(value is not None for value in [x, y, width, height]):
|
||||
element = {
|
||||
"id": element_id,
|
||||
@@ -534,8 +623,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"rotation": rotation,
|
||||
"order": len(elements),
|
||||
}
|
||||
if kind == "table":
|
||||
element.update(
|
||||
{
|
||||
"declared_width": extract_numeric_attribute(attrs, "width"),
|
||||
"declared_height": extract_numeric_attribute(attrs, "height"),
|
||||
"table_layouts": table_layouts,
|
||||
}
|
||||
)
|
||||
if kind == "shape":
|
||||
element.update(
|
||||
{
|
||||
@@ -867,11 +965,158 @@ def detect_whiteboard_external_overlaps(
|
||||
return issues
|
||||
|
||||
|
||||
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
||||
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
||||
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
||||
return bbox
|
||||
|
||||
rotation = element["rotation"]
|
||||
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
||||
rotation = 0
|
||||
rotation %= 360
|
||||
if math.isclose(rotation, 0, abs_tol=1e-9):
|
||||
return bbox
|
||||
radians = math.radians(rotation)
|
||||
sine = abs(math.sin(radians))
|
||||
cosine = abs(math.cos(radians))
|
||||
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
|
||||
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
|
||||
rotated_width = element["width"] * cosine + element["height"] * sine
|
||||
rotated_height = element["width"] * sine + element["height"] * cosine
|
||||
return {
|
||||
"x": element["x"] - (rotated_width - element["width"]) / 2,
|
||||
"y": element["y"] - (rotated_height - element["height"]) / 2,
|
||||
"width": rotated_width,
|
||||
"height": rotated_height,
|
||||
}
|
||||
|
||||
|
||||
def detect_elements_out_of_canvas(
|
||||
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in (
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] == "text")
|
||||
):
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
"top": max(-bbox["y"], 0),
|
||||
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
|
||||
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
|
||||
}
|
||||
overflow_details = [
|
||||
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
|
||||
]
|
||||
if not overflow_details:
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": f'{element["kind"]}_out_of_canvas',
|
||||
"elements": [element["id"]],
|
||||
"canvas": {"width": slide_width, "height": slide_height},
|
||||
"bbox": bbox,
|
||||
"overflow": overflow,
|
||||
"message": (
|
||||
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
|
||||
f'({", ".join(overflow_details)})'
|
||||
),
|
||||
"hint": (
|
||||
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
|
||||
"slides."
|
||||
if element["kind"] == "table"
|
||||
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
|
||||
sizes: list[int | float | None] = []
|
||||
for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
|
||||
attrs = match.group(1)
|
||||
span = extract_numeric_attribute(attrs, "span") or 1
|
||||
span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
|
||||
sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
|
||||
return sizes
|
||||
|
||||
|
||||
def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
|
||||
return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
|
||||
|
||||
|
||||
def resolve_table_dimension(
|
||||
table_xml: str,
|
||||
declared_size: int | float | None,
|
||||
extract_sizes: Any,
|
||||
default_size: int | float,
|
||||
) -> tuple[int | float | None, dict[str, Any] | None]:
|
||||
input_sizes = extract_sizes(table_xml)
|
||||
if not input_sizes:
|
||||
return declared_size, None
|
||||
layout = solve_weighted_min_layout(
|
||||
input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
|
||||
)
|
||||
return layout["actual_size"], layout
|
||||
|
||||
|
||||
def format_size(size: int | float) -> str:
|
||||
return f"{size:g}"
|
||||
|
||||
|
||||
def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
dimensions = {
|
||||
"width": ("col", "column widths"),
|
||||
"height": ("tr", "row heights"),
|
||||
}
|
||||
for table in (element for element in elements if element["kind"] == "table"):
|
||||
for dimension, (child_tag, child_description) in dimensions.items():
|
||||
target_size = table[f"declared_{dimension}"]
|
||||
if not is_filled_size(target_size):
|
||||
continue
|
||||
layout = table["table_layouts"][dimension]
|
||||
if layout is None:
|
||||
continue
|
||||
actual_size = layout["actual_size"]
|
||||
if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "info",
|
||||
"code": "table_resolved_size_mismatch",
|
||||
"elements": [table["id"]],
|
||||
"dimension": dimension,
|
||||
"declared_size": target_size,
|
||||
"resolved_size": actual_size,
|
||||
"resolved_sizes": layout["final_sizes"],
|
||||
"message": (
|
||||
f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
|
||||
f"{child_description} resolve to {format_size(actual_size)}px"
|
||||
),
|
||||
"hint": (
|
||||
f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
|
||||
f"so their resolved total matches {format_size(target_size)}px."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
elements = extract_elements(slide_xml)
|
||||
issues: list[dict[str, Any]] = detect_whiteboard_external_overlaps(elements, slide_width, slide_height)
|
||||
issues: list[dict[str, Any]] = [
|
||||
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
|
||||
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
@@ -896,7 +1141,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
|
||||
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
|
||||
"issues": [xml_error],
|
||||
"slides": [],
|
||||
}
|
||||
@@ -908,10 +1153,16 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
if namespace_issues:
|
||||
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {"slide_count": 0, "error_count": error_count, "warning_count": warning_count},
|
||||
"summary": {
|
||||
"slide_count": 0,
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
},
|
||||
"issues": top_level_issues,
|
||||
"slides": [],
|
||||
}
|
||||
@@ -924,10 +1175,17 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
|
||||
result = {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
|
||||
"summary": {
|
||||
"slide_count": len(slides),
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
if top_level_issues:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import xml_text_overlap_lint
|
||||
|
||||
@@ -212,7 +217,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
|
||||
self.assertEqual(result["summary"]["slide_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
|
||||
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -596,7 +602,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
|
||||
|
||||
def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
|
||||
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -613,8 +619,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(issue["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
|
||||
|
||||
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -669,7 +678,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(elements[1]["fontSize"], 28)
|
||||
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
|
||||
|
||||
def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
|
||||
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -683,7 +692,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
|
||||
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -698,7 +707,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
|
||||
def test_lint_xml_allows_full_bleed_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -712,6 +721,339 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
|
||||
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
|
||||
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
|
||||
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
|
||||
[
|
||||
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
|
||||
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
|
||||
<chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
|
||||
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
|
||||
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
|
||||
|
||||
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
|
||||
<shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
|
||||
<chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 3)
|
||||
self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
|
||||
self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
|
||||
self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
|
||||
|
||||
def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["elements"], ["score-table"])
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
|
||||
self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})
|
||||
|
||||
def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})
|
||||
|
||||
def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
|
||||
<tr><td><content><p>Score</p></content></td></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="implicit-size-table" topLeftX="850" topLeftY="480">
|
||||
<colgroup><col/><col/></colgroup>
|
||||
<tr><td/><td/></tr>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "table_out_of_canvas")
|
||||
self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})
|
||||
|
||||
def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
|
||||
<colgroup><col width="100"/><col width="100"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
||||
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
|
||||
self.assertEqual(canvas_issue["overflow"]["right"], 40)
|
||||
self.assertEqual(mismatch_issue["dimension"], "width")
|
||||
self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])
|
||||
|
||||
def test_lint_xml_uses_the_same_anonymous_table_id_for_all_table_diagnostics(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
|
||||
<img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
|
||||
<table topLeftX="900" topLeftY="80" width="100" height="40">
|
||||
<colgroup><col width="100"/><col width="100"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
|
||||
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
|
||||
self.assertEqual(canvas_issue["elements"], ["table-3"])
|
||||
self.assertEqual(mismatch_issue["elements"], ["table-3"])
|
||||
|
||||
def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
|
||||
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
||||
<tr height="40"><td/><td/><td/></tr>
|
||||
<tr height="60"><td/><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "info")
|
||||
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
|
||||
self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
|
||||
self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)
|
||||
|
||||
def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
|
||||
<colgroup><col width="100"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
|
||||
<colgroup><col/><col/></colgroup>
|
||||
<tr><td/><td/></tr>
|
||||
</table>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["dimension"], "width")
|
||||
self.assertEqual(issue["resolved_sizes"], [1, 1])
|
||||
self.assertEqual(issue["resolved_size"], 2)
|
||||
|
||||
def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
|
||||
final_sizes = xml_text_overlap_lint.fill_last_size_gap([10, 10], 3)
|
||||
self.assertEqual(final_sizes, [2, 1])
|
||||
self.assertEqual(sum(final_sizes), 3)
|
||||
|
||||
def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
|
||||
cases = {
|
||||
"target-exact": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="360" height="150">
|
||||
<colgroup><col width="100"/><col width="200"/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
0,
|
||||
),
|
||||
"declared-size-exceeds-target": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="200" height="80">
|
||||
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
|
||||
<tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
2,
|
||||
),
|
||||
"remaining-space-insufficient": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120" width="80" height="30">
|
||||
<colgroup><col width="80"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
2,
|
||||
),
|
||||
"no-target-size": (
|
||||
"""
|
||||
<table topLeftX="40" topLeftY="120">
|
||||
<colgroup><col width="80"/><col/></colgroup>
|
||||
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
|
||||
</table>
|
||||
""",
|
||||
0,
|
||||
),
|
||||
}
|
||||
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for name, (table_xml, expected_info_count) in cases.items():
|
||||
with self.subTest(case=name):
|
||||
input_path = Path(temp_dir) / f"{name}.xml"
|
||||
input_path.write_text(
|
||||
f"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
|
||||
</presentation>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(script_path), "--input", str(input_path)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
result = json.loads(completed.stdout)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], expected_info_count)
|
||||
self.assertTrue(
|
||||
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
|
||||
result["slides"][0]["issues"],
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_for_whiteboard_external_boundary_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
@@ -92,7 +92,7 @@ metadata:
|
||||
### 3. 发送会中文本或会中表情(写操作)
|
||||
|
||||
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。发消息只需 `meeting_id`,不要先查 `+detail`。
|
||||
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`。
|
||||
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type`。`--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
|
||||
5. 支持普通 Feishu reaction emoji(如 `LOVE`、`SMILE`、`THUMBSUP`)和 4 个 VC 反馈 key(`VC_CanNotSee`、`VC_NoSound`、`VC_LooksGood`、`VC_SoundsClear`)。
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBase_BasicWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBase_RoleWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
|
||||
func TestCalendar_CreateEvent(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
|
||||
func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -39,6 +39,7 @@ func requireFreebusyEntry(t *testing.T, stdout string, startAt time.Time, endAt
|
||||
}
|
||||
|
||||
func TestCalendar_RSVPWorkflowAsUser(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCalendar_UpdateEventWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -52,6 +52,7 @@ func TestContact_LookupWorkflowAsUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContact_LookupWorkflowAsBot(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const (
|
||||
|
||||
func SkipWithoutUserToken(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
|
||||
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" || os.Getenv("TEST_USER_ACCESS_TOKEN") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,6 +75,27 @@ func SkipWithoutUserToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func SkipWithoutTenantAccessToken(t *testing.T) {
|
||||
t.Helper()
|
||||
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN")
|
||||
}
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
if appID == "" {
|
||||
appID = os.Getenv("LARKSUITE_CLI_APP_ID")
|
||||
}
|
||||
if token == "" || appID == "" {
|
||||
t.Skip("skipped: tenant test credentials not set")
|
||||
}
|
||||
|
||||
// Scope standard env credentials to tests that explicitly require a live
|
||||
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
|
||||
// config and dry-run CLI subprocesses from activating the env provider.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
|
||||
}
|
||||
|
||||
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
|
||||
func DryRunGet(stdout, path string) gjson.Result {
|
||||
if path == "" {
|
||||
@@ -225,13 +246,13 @@ func buildCommandEnv(req Request) []string {
|
||||
overrides[k] = v
|
||||
}
|
||||
// Keep user-token injection scoped to user-only test commands so bot
|
||||
// commands continue to use config-init credentials in the same process.
|
||||
// commands retain the process-level bot credentials.
|
||||
if req.DefaultAs == "user" {
|
||||
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
}
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
}
|
||||
for k, v := range overrides {
|
||||
|
||||
@@ -113,6 +113,19 @@ func TestSkipWithoutUserToken(t *testing.T) {
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("returns immediately when test user access token exists", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat-from-test-env")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutUserToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("accepts verified local auth status", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
@@ -146,6 +159,54 @@ func TestSkipWithoutUserToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
t.Run("skips when env tenant access token is missing", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.False(t, ran)
|
||||
})
|
||||
|
||||
t.Run("accepts standard tenant credentials", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app-from-env")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "test-token")
|
||||
|
||||
ran := false
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
ran = true
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCmd(t *testing.T) {
|
||||
t.Run("returns stdout json on success", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
@@ -214,6 +275,8 @@ func TestRunCmd(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("injects user token env only for user commands", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
|
||||
|
||||
@@ -224,6 +287,10 @@ func TestRunCmd(t *testing.T) {
|
||||
env = buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
|
||||
func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
|
||||
func TestDocs_UpdateWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, asserts each delete returns `task_id`, queries every returned task via `drive +task_result --scenario task_check`, and verifies the targets disappear.
|
||||
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, converging every delete outcome to the resource-gone terminal state: async deletes (non-empty `task_id`) are verified via `drive +task_result --scenario task_check`, sync deletes (empty `task_id`) assert `deleted=true`, and the one verified backend transient (`server_error: "drive task failed"`) passes once the target is confirmed gone (retried up to 3 times otherwise); any other delete failure stays fatal.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -30,7 +30,7 @@
|
||||
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
|
||||
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_AppsPageURL; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; apps `/page/<token>` URL; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, apps `file_type=apps`, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder async deletion |
|
||||
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
|
||||
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
|
||||
}
|
||||
|
||||
370
tests/cli_e2e/drive/drive_delete_workflow_helper_test.go
Normal file
370
tests/cli_e2e/drive/drive_delete_workflow_helper_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeleteAsyncAndVerify(t *testing.T) {
|
||||
t.Run("sync delete without task_id skips task_result", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "sync")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_sync", "docx")
|
||||
assert.Empty(t, taskID)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "sync path must delete exactly once")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "sync path must still verify the resource is gone")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults), "sync path must not query task status")
|
||||
})
|
||||
|
||||
t.Run("transient failure with resource gone is tolerated", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_transient", "docx")
|
||||
assert.Empty(t, taskID)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "resource already gone must not trigger another delete attempt")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "transient failure must verify the terminal state")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
})
|
||||
|
||||
t.Run("failed delete retries until async success", func(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail-then-async")
|
||||
t.Setenv("FAKE_WORKFLOW_META_MODE", "exists-then-gone")
|
||||
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_OK", "1")
|
||||
counters := setupFakeWorkflowCounters(t)
|
||||
withFastDeleteWorkflowBackoff(t)
|
||||
|
||||
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_retry", "docx")
|
||||
assert.Equal(t, "task_123", taskID)
|
||||
|
||||
assert.Equal(t, "2", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "2", readFakeCounter(t, counters.metas), "one terminal-state check after the failure plus the final visibility wait")
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults), "async success must verify the task result")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsTransientDriveDeleteFailure locks the tolerance boundary: only the one
|
||||
// verified backend transient may fall through to terminal-state checking, so a
|
||||
// crash, a protocol regression, or any other error keeps failing the workflow
|
||||
// even when the resource happens to be gone.
|
||||
func TestIsTransientDriveDeleteFailure(t *testing.T) {
|
||||
t.Run("matches compact envelope", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "Deleting docx tok...\n{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"drive task failed\"}}",
|
||||
}
|
||||
assert.True(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("matches pretty-printed envelope from CI", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "Deleting docx NTw0...Rngb...\nDelete is async, polling task schedule|7663369798226545963...\n" +
|
||||
"{\n \"ok\": false,\n \"identity\": \"bot\",\n \"error\": {\n \"type\": \"api\",\n \"subtype\": \"server_error\",\n \"message\": \"drive task failed\"\n }\n}",
|
||||
}
|
||||
assert.True(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects other server errors", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"internal error\"}}",
|
||||
}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects non-server-error subtypes", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"permission_denied\",\"message\":\"drive task failed\"}}",
|
||||
}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects non-JSON output", func(t *testing.T) {
|
||||
result := &clie2e.Result{ExitCode: 2, Stderr: "panic: runtime error"}
|
||||
assert.False(t, isTransientDriveDeleteFailure(result))
|
||||
})
|
||||
|
||||
t.Run("rejects nil result", func(t *testing.T) {
|
||||
assert.False(t, isTransientDriveDeleteFailure(nil))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyRejectsUnexpectedFailure locks the P1 boundary
|
||||
// end-to-end by re-running this test binary as a subprocess that really calls
|
||||
// deleteAsyncAndVerify: an unrelated non-zero exit must fail the helper
|
||||
// immediately — no terminal-state check may rescue it even though meta reports
|
||||
// the resource gone. Fatalf cannot be observed on the parent *testing.T, so
|
||||
// the boundary is proven by the child process exiting non-zero AND the meta
|
||||
// endpoint never being reached. Removing the isTransientDriveDeleteFailure
|
||||
// guard from the main loop turns this test red.
|
||||
func TestDeleteAsyncAndVerifyRejectsUnexpectedFailure(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_unexpected",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "fail-unexpected",
|
||||
"FAKE_WORKFLOW_META_MODE": "gone",
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process on an unexpected delete error\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +delete failed with an unexpected error", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "unexpected failures must not fall through to terminal-state checking")
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyFailsOnTaskResultFailure proves a non-zero
|
||||
// drive +task_result exit fails the workflow before the final visibility
|
||||
// polling: the task-result endpoint is reached once and the meta endpoint
|
||||
// never.
|
||||
func TestDeleteAsyncAndVerifyFailsOnTaskResultFailure(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_taskresult",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "async",
|
||||
"FAKE_WORKFLOW_META_MODE": "gone",
|
||||
// FAKE_WORKFLOW_TASK_RESULT_OK stays unset: +task_result exits 2.
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process when +task_result fails\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +task_result failed", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "task-result failure must abort before visibility polling")
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries proves the transient
|
||||
// tolerance is bounded: with the resource still present, exactly
|
||||
// deleteWorkflowMaxAttempts delete attempts (each followed by one terminal
|
||||
// state check) run before the workflow fails for good.
|
||||
func TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries(t *testing.T) {
|
||||
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
|
||||
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
|
||||
"FAKE_WORKFLOW_TOKEN": "docx_exhausted",
|
||||
"FAKE_WORKFLOW_DELETE_MODE": "fail",
|
||||
"FAKE_WORKFLOW_META_MODE": "exists",
|
||||
"FAKE_WORKFLOW_FAST_BACKOFF": "1",
|
||||
})
|
||||
require.Error(t, err, "deleteAsyncAndVerify must fail the test process after exhausting retries\noutput:\n%s", output)
|
||||
assert.Contains(t, output, "drive +delete failed 3 times", "output:\n%s", output)
|
||||
|
||||
assert.Equal(t, "3", readFakeCounter(t, counters.deletes))
|
||||
assert.Equal(t, "3", readFakeCounter(t, counters.metas))
|
||||
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
|
||||
}
|
||||
|
||||
// runDeleteWorkflowSubprocess re-runs this test binary anchored to the child
|
||||
// entry point below with the fake CLI and counter files wired in via env.
|
||||
func runDeleteWorkflowSubprocess(t *testing.T, fake string, counters fakeWorkflowCounters, env map[string]string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestDeleteAsyncAndVerifySubprocess$", "-test.v")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"FAKE_WORKFLOW_SUBPROCESS=1",
|
||||
clie2e.EnvBinaryPath+"="+fake,
|
||||
"FAKE_WORKFLOW_DELETE_STATE="+counters.deletes,
|
||||
"FAKE_WORKFLOW_META_STATE="+counters.metas,
|
||||
"FAKE_WORKFLOW_TASK_RESULT_STATE="+counters.taskResults,
|
||||
)
|
||||
for k, v := range env {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// TestDeleteAsyncAndVerifySubprocess is the child entry point driven by
|
||||
// runDeleteWorkflowSubprocess. It does nothing in a normal test run.
|
||||
func TestDeleteAsyncAndVerifySubprocess(t *testing.T) {
|
||||
if os.Getenv("FAKE_WORKFLOW_SUBPROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
if os.Getenv("FAKE_WORKFLOW_FAST_BACKOFF") == "1" {
|
||||
deleteWorkflowRetryBackoff = time.Millisecond
|
||||
}
|
||||
deleteAsyncAndVerify(t, context.Background(), os.Getenv("FAKE_WORKFLOW_TOKEN"), "docx")
|
||||
}
|
||||
|
||||
type fakeWorkflowCounters struct {
|
||||
deletes string
|
||||
metas string
|
||||
taskResults string
|
||||
}
|
||||
|
||||
func newFakeWorkflowCounterPaths(t *testing.T) fakeWorkflowCounters {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
return fakeWorkflowCounters{
|
||||
deletes: filepath.Join(dir, "delete-attempts"),
|
||||
metas: filepath.Join(dir, "meta-calls"),
|
||||
taskResults: filepath.Join(dir, "task-result-calls"),
|
||||
}
|
||||
}
|
||||
|
||||
// setupFakeWorkflowCounters wires per-endpoint call counters into the fake CLI
|
||||
// so tests can assert exactly which commands ran.
|
||||
func setupFakeWorkflowCounters(t *testing.T) fakeWorkflowCounters {
|
||||
t.Helper()
|
||||
|
||||
counters := newFakeWorkflowCounterPaths(t)
|
||||
t.Setenv("FAKE_WORKFLOW_DELETE_STATE", counters.deletes)
|
||||
t.Setenv("FAKE_WORKFLOW_META_STATE", counters.metas)
|
||||
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_STATE", counters.taskResults)
|
||||
return counters
|
||||
}
|
||||
|
||||
func readFakeCounter(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "0"
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func withFastDeleteWorkflowBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
original := deleteWorkflowRetryBackoff
|
||||
deleteWorkflowRetryBackoff = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
deleteWorkflowRetryBackoff = original
|
||||
})
|
||||
}
|
||||
|
||||
// mustWriteDriveDeleteWorkflowFakeCLI writes a fake lark-cli that emulates the
|
||||
// drive delete outcomes exercised by deleteAsyncAndVerify. Every endpoint
|
||||
// bumps a per-endpoint counter when its FAKE_WORKFLOW_*_STATE env is set, so
|
||||
// tests can assert call contracts. +task_result rejects every call unless
|
||||
// FAKE_WORKFLOW_TASK_RESULT_OK=1, which proves the sync path never queries
|
||||
// task status.
|
||||
func mustWriteDriveDeleteWorkflowFakeCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
bump_counter() {
|
||||
state="$1"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
next=$((count + 1))
|
||||
printf '%s' "$next" > "$state"
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
|
||||
count=0
|
||||
if [ -n "$FAKE_WORKFLOW_DELETE_STATE" ]; then
|
||||
count="$(bump_counter "$FAKE_WORKFLOW_DELETE_STATE")"
|
||||
fi
|
||||
case "$FAKE_WORKFLOW_DELETE_MODE" in
|
||||
sync)
|
||||
echo '{"ok":true,"identity":"bot","data":{"deleted":true,"file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
fail)
|
||||
echo "Deleting docx tok..." >&2
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
|
||||
exit 1
|
||||
;;
|
||||
fail-unexpected)
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"invalid_request","message":"file token not found"}}' >&2
|
||||
exit 1
|
||||
;;
|
||||
async)
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
fail-then-async)
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "unexpected FAKE_WORKFLOW_DELETE_MODE: $FAKE_WORKFLOW_DELETE_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+task_result" ]; then
|
||||
if [ -n "$FAKE_WORKFLOW_TASK_RESULT_STATE" ]; then
|
||||
bump_counter "$FAKE_WORKFLOW_TASK_RESULT_STATE" > /dev/null
|
||||
fi
|
||||
if [ "${FAKE_WORKFLOW_TASK_RESULT_OK:-0}" != "1" ]; then
|
||||
echo "unexpected +task_result call: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","failed":false}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
|
||||
count=0
|
||||
if [ -n "$FAKE_WORKFLOW_META_STATE" ]; then
|
||||
count="$(bump_counter "$FAKE_WORKFLOW_META_STATE")"
|
||||
fi
|
||||
case "$FAKE_WORKFLOW_META_MODE" in
|
||||
gone)
|
||||
echo '{"ok":true,"data":{"metas":[]}}'
|
||||
exit 0
|
||||
;;
|
||||
exists)
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
;;
|
||||
exists-then-gone)
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
fi
|
||||
echo '{"ok":true,"data":{"metas":[]}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "unexpected FAKE_WORKFLOW_META_MODE: $FAKE_WORKFLOW_META_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "unexpected fake CLI args: $*" >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
|
||||
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
|
||||
return binaryPath
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDrive_DeleteAsyncWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
@@ -65,30 +68,117 @@ func createDeleteWorkflowDoc(t *testing.T, ctx context.Context, folderToken, tit
|
||||
return docToken
|
||||
}
|
||||
|
||||
const deleteWorkflowMaxAttempts = 3
|
||||
|
||||
// deleteWorkflowRetryBackoff paces delete retries after a non-retryable
|
||||
// failure whose target still exists. Unit tests shrink it.
|
||||
var deleteWorkflowRetryBackoff = driveDeleteVisibilityPoll
|
||||
|
||||
// deleteAsyncAndVerify deletes token and converges every server outcome to the
|
||||
// real postcondition: the resource is gone. Async deletes (non-empty task_id)
|
||||
// additionally verify the task via drive +task_result; sync deletes (empty
|
||||
// task_id) skip task polling; non-retryable delete failures (e.g. a transient
|
||||
// "drive task failed") pass when the resource is already gone and are retried
|
||||
// up to deleteWorkflowMaxAttempts times otherwise.
|
||||
func deleteAsyncAndVerify(t *testing.T, ctx context.Context, token, docType string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, driveDeleteRetry)
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
var lastResult *clie2e.Result
|
||||
for attempt := 1; attempt <= deleteWorkflowMaxAttempts; attempt++ {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, driveDeleteRetry)
|
||||
require.NoError(t, err)
|
||||
lastResult = result
|
||||
|
||||
taskID := gjson.Get(result.Stdout, "data.task_id").String()
|
||||
require.NotEmpty(t, taskID, "delete must return async task_id\nstdout:\n%s", result.Stdout)
|
||||
if result.ExitCode == 0 {
|
||||
result.AssertStdoutStatus(t, true)
|
||||
taskID := gjson.Get(result.Stdout, "data.task_id").String()
|
||||
if taskID == "" {
|
||||
// Sync completion: the server deleted the resource inline and
|
||||
// returned no task to poll.
|
||||
require.True(t, gjson.Get(result.Stdout, "data.deleted").Bool(), "sync delete must report deleted=true\nstdout:\n%s", result.Stdout)
|
||||
t.Logf("drive +delete completed synchronously for %s %s (no task_id)", docType, token)
|
||||
} else {
|
||||
assertDriveDeleteTaskSucceeded(t, ctx, taskID)
|
||||
}
|
||||
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
|
||||
return taskID
|
||||
}
|
||||
|
||||
// Only the one verified backend transient may fall through to
|
||||
// terminal-state checking; any other failure is a real regression and
|
||||
// must not be rescued by the resource happening to be gone.
|
||||
if !isTransientDriveDeleteFailure(result) {
|
||||
t.Fatalf("drive +delete failed with an unexpected error on attempt %d\nstdout:\n%s\nstderr:\n%s",
|
||||
attempt, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
// The failed delete task may still have removed the resource
|
||||
// server-side, so check the real terminal state before retrying.
|
||||
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, "bot")
|
||||
require.NoError(t, verifyErr, "verify %s %s after failed delete attempt %d", docType, token, attempt)
|
||||
if deleted {
|
||||
t.Logf("drive +delete attempt %d failed transiently but %s %s is gone: stderr=%s", attempt, docType, token, result.Stderr)
|
||||
return ""
|
||||
}
|
||||
if attempt < deleteWorkflowMaxAttempts {
|
||||
t.Logf("drive +delete attempt %d failed and %s %s still exists; retrying: stderr=%s", attempt, docType, token, result.Stderr)
|
||||
time.Sleep(deleteWorkflowRetryBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("drive +delete failed %d times and %s %s still exists\nstdout:\n%s\nstderr:\n%s",
|
||||
deleteWorkflowMaxAttempts, docType, token, lastResult.Stdout, lastResult.Stderr)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertDriveDeleteTaskSucceeded(t *testing.T, ctx context.Context, taskID string) {
|
||||
t.Helper()
|
||||
|
||||
taskResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+task_result", "--scenario", "task_check", "--task-id", taskID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
taskResult.AssertExitCode(t, 0)
|
||||
require.NotNil(t, taskResult)
|
||||
// Fatal exit-code gate first: the non-fatal assert flavor would cascade
|
||||
// into misleading empty-stdout failures, exactly what this fix removes.
|
||||
require.Equal(t, 0, taskResult.ExitCode, "drive +task_result failed\nstdout:\n%s\nstderr:\n%s", taskResult.Stdout, taskResult.Stderr)
|
||||
taskResult.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, taskID, gjson.Get(taskResult.Stdout, "data.task_id").String(), "stdout:\n%s", taskResult.Stdout)
|
||||
require.False(t, gjson.Get(taskResult.Stdout, "data.failed").Bool(), "stdout:\n%s", taskResult.Stdout)
|
||||
|
||||
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
|
||||
return taskID
|
||||
// gjson returns false for an absent field too, so require presence or a
|
||||
// malformed task envelope would pass validation.
|
||||
failedField := gjson.Get(taskResult.Stdout, "data.failed")
|
||||
require.True(t, failedField.Exists(), "task result must report data.failed\nstdout:\n%s", taskResult.Stdout)
|
||||
require.False(t, failedField.Bool(), "stdout:\n%s", taskResult.Stdout)
|
||||
}
|
||||
|
||||
// isTransientDriveDeleteFailure reports whether a failed drive +delete carries
|
||||
// the one backend error this workflow tolerates: the async delete task
|
||||
// transiently reporting a terminal "fail" state (observed as flake in CI; the
|
||||
// resource is usually deleted regardless). Everything else — crashes, protocol
|
||||
// regressions, auth or parameter errors — stays fatal.
|
||||
func isTransientDriveDeleteFailure(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range []string{result.Stderr, result.Stdout} {
|
||||
idx := strings.Index(raw, "{")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
payload := raw[idx:]
|
||||
if !gjson.Valid(payload) {
|
||||
continue
|
||||
}
|
||||
errObj := gjson.Get(payload, "error")
|
||||
if errObj.Get("type").String() == "api" &&
|
||||
errObj.Get("subtype").String() == "server_error" &&
|
||||
errObj.Get("message").String() == "drive task failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
|
||||
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user