mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
31 Commits
v1.0.70
...
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 | ||
|
|
708196040a | ||
|
|
65586577a3 | ||
|
|
be1f3621de | ||
|
|
65998a21e3 | ||
|
|
d5afe3f705 | ||
|
|
baf6050f8e | ||
|
|
a6bc81596a | ||
|
|
7f43b7ed5d | ||
|
|
5fb70d326a |
169
.github/workflows/ci.yml
vendored
169
.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
|
||||
@@ -47,6 +54,34 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -176,7 +211,11 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
||||
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
@@ -263,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:
|
||||
@@ -276,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
|
||||
@@ -309,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:
|
||||
@@ -329,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"
|
||||
@@ -363,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
|
||||
@@ -416,7 +520,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -436,10 +540,19 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Graduation to required is tracked in
|
||||
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
||||
# consecutive weeks with zero false positives).
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -2,6 +2,52 @@
|
||||
|
||||
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
|
||||
|
||||
- add wiki move-to-drive shortcut (#1869)
|
||||
- **apps**: add role management shortcuts (#1881)
|
||||
- **drive**: add secure label support and clarify comment location API (#1913)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve dashboard shortcut guidance (#1787)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
|
||||
|
||||
### Misc
|
||||
|
||||
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
|
||||
- **drive**: optimize drive +delete workflow (#1909)
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
@@ -1506,6 +1552,8 @@ 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
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
|
||||
15
Makefile
15
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -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
|
||||
@@ -64,6 +64,9 @@ examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
|
||||
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
|
||||
# Deliberate: local `make test` exercises the L4 plugin contract by default.
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
@@ -105,6 +108,14 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
28
internal/errclass/codemeta_spark.go
Normal file
28
internal/errclass/codemeta_spark.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// sparkCodeMeta holds stable Spark app-role business-code classifications.
|
||||
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
|
||||
// numeric code remains the source-specific discriminator on the error envelope.
|
||||
var sparkCodeMeta = map[int]CodeMeta{
|
||||
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
|
||||
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
|
||||
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
|
||||
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
|
||||
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
|
||||
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
|
||||
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
|
||||
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
|
||||
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
|
||||
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
|
||||
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
|
||||
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
|
||||
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
|
||||
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }
|
||||
59
internal/errclass/codemeta_spark_test.go
Normal file
59
internal/errclass/codemeta_spark_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
|
||||
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
|
||||
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
|
||||
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
|
||||
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tt.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d is not registered", tt.code)
|
||||
}
|
||||
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
|
||||
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
|
||||
}
|
||||
|
||||
err := BuildAPIError(map[string]any{
|
||||
"code": tt.code,
|
||||
"msg": "spark role error",
|
||||
"log_id": "log-spark-role",
|
||||
}, ClassifyContext{Identity: "user"})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
|
||||
}
|
||||
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
|
||||
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
|
||||
return "ou_test123", true
|
||||
case hasPlaceholderToken(tokens, "department", "dept"):
|
||||
return "od_test123", true
|
||||
return "od-test123", true
|
||||
case hasPlaceholderToken(tokens, "message"):
|
||||
return "om_test123", true
|
||||
case name == "file_key":
|
||||
|
||||
@@ -316,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
|
||||
got, ok := fakeValueFromPlaceholderName("open_department_id")
|
||||
if !ok || got != "od-test123" {
|
||||
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
|
||||
@@ -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.70",
|
||||
"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();
|
||||
}
|
||||
});
|
||||
@@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"scope": "Range",
|
||||
"users": []interface{}{"ou_x", "ou_y"},
|
||||
"departments": []interface{}{"od_z"},
|
||||
"departments": []interface{}{"od-z"},
|
||||
"chats": []interface{}{"oc_g"},
|
||||
"apply_config": map[string]interface{}{
|
||||
"enabled": true,
|
||||
@@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
|
||||
if !strings.Contains(got, `"scope": "Range"`) {
|
||||
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
|
||||
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) {
|
||||
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_appr"`) {
|
||||
|
||||
744
shortcuts/apps/apps_role.go
Normal file
744
shortcuts/apps/apps_role.go
Normal file
@@ -0,0 +1,744 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRoleListScanPages = 1000
|
||||
|
||||
// AppsRoleList lists app roles.
|
||||
var AppsRoleList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-list",
|
||||
Description: "List app roles",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-list --app-id <app_id>",
|
||||
"Example: lark-cli apps +role-list --app-id <app_id> --name Admin --page-size 20",
|
||||
"When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id",
|
||||
"With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "name", Desc: "filter roles by exact name"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "integer offset returned by the previous role-list response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleListParams(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleListParams; error is impossible here.
|
||||
params, _ := buildRoleListParams(rctx)
|
||||
params = roleListRequestParams(params, 0)
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleListURL(rctx)).
|
||||
Desc("List app roles").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
params, err := buildRoleListParams(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := executeRoleList(rctx, params)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationList)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleListPretty(w, common.GetSlice(data, "items"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleGet gets one app role.
|
||||
var AppsRoleGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-get",
|
||||
Description: "Get an app role",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-get --app-id <app_id> --role-id <role_id>",
|
||||
"--role-id is not a human-readable role name; if only a name is known, run +role-list --name <exact_name> and use its unique returned role_id before calling +role-get",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return validateRoleID(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleItemURL(rctx)).
|
||||
Desc("Get app role")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationGet)
|
||||
}
|
||||
role, err := parseRoleDetailResponseData(data, roleID(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleGetPretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleCreate creates an app role.
|
||||
var AppsRoleCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-create",
|
||||
Description: "Create an app role",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin",
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --description 'Can manage orders'",
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --role-id role_admin",
|
||||
"The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
// Keep --name in Validate so the CLI can return the command-specific
|
||||
// non-invention hint instead of Cobra's generic required-flag error.
|
||||
{Name: "name", Desc: "role name (required)"},
|
||||
{Name: "description", Desc: "role description"},
|
||||
{Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required").
|
||||
WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description")
|
||||
}
|
||||
if rctx.Changed("role-id") {
|
||||
roleID := strings.TrimSpace(rctx.Str("role-id"))
|
||||
if roleID == "" {
|
||||
return appsValidationParamError("--role-id", "--role-id must not be empty when provided")
|
||||
}
|
||||
return validateOptionalRoleID(roleID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleListURL(rctx)).
|
||||
Desc("Create app role").
|
||||
Body(buildRoleCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationCreate)
|
||||
}
|
||||
expectedRoleID := ""
|
||||
if rctx.Changed("role-id") {
|
||||
expectedRoleID = roleID(rctx)
|
||||
}
|
||||
role, err := parseRoleWriteResponseData(data, expectedRoleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleCreatePretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleUpdate updates an app role.
|
||||
var AppsRoleUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-update",
|
||||
Description: "Update an app role",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-update --app-id <app_id> --role-id <role_id> --name Operator",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "name", Desc: "new role name"},
|
||||
{Name: "description", Desc: "new role description"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name must not be empty when provided").
|
||||
WithHint("omit --name if only updating --description")
|
||||
}
|
||||
if !rctx.Changed("name") && !rctx.Changed("description") {
|
||||
reason := "provide at least one of --name or --description"
|
||||
return appsValidationError("at least one of --name or --description is required").
|
||||
WithParams(
|
||||
appsInvalidParam("--name", reason),
|
||||
appsInvalidParam("--description", reason),
|
||||
).
|
||||
WithHint("provide --name, --description, or both")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(roleItemURL(rctx)).
|
||||
Desc("Update app role").
|
||||
Body(buildRoleUpdateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationUpdate)
|
||||
}
|
||||
role, err := parseRoleWriteResponseData(data, roleID(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleUpdatePretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleDelete deletes an app role.
|
||||
var AppsRoleDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-delete",
|
||||
Description: "Delete an app role",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes",
|
||||
"A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact",
|
||||
"When independent verification is required, use +role-list --name <exact_name> and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return validateRoleID(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE(roleItemURL(rctx)).
|
||||
Desc("Delete app role")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationDelete)
|
||||
}
|
||||
deletedRoleID := roleID(rctx)
|
||||
out, err := normalizeRoleDeleteData(data, deletedRoleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleDeletePretty(w, common.GetString(out, "role_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func roleListURL(rctx *common.RuntimeContext) string {
|
||||
appID := roleAppID(rctx)
|
||||
return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func roleItemURL(rctx *common.RuntimeContext) string {
|
||||
appID := roleAppID(rctx)
|
||||
roleID := roleID(rctx)
|
||||
return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID))
|
||||
}
|
||||
|
||||
func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
params, err := buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
if rctx.Changed("name") && name == "" {
|
||||
return nil, appsValidationParamError("--name", "--name must not be empty when provided").
|
||||
WithHint("omit --name to list all roles, or provide the exact role name to resolve")
|
||||
}
|
||||
if name != "" {
|
||||
params["name"] = name
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// roleListRequestParams returns the query parameters for one actual backend
|
||||
// request. Exact-name lookup always starts from server offset zero and scans in
|
||||
// maximum-sized batches; the caller's limit/offset are applied to local matches.
|
||||
func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} {
|
||||
name, _ := params["name"].(string)
|
||||
if name == "" {
|
||||
return params
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"limit": maxRolePageSize,
|
||||
"offset": page * maxRolePageSize,
|
||||
"name": name,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
}
|
||||
if rctx.Changed("description") {
|
||||
body["description"] = strings.TrimSpace(rctx.Str("description"))
|
||||
}
|
||||
if rctx.Changed("role-id") {
|
||||
if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" {
|
||||
body["role_id"] = roleID
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if rctx.Changed("name") {
|
||||
body["name"] = strings.TrimSpace(rctx.Str("name"))
|
||||
}
|
||||
if rctx.Changed("description") {
|
||||
body["description"] = strings.TrimSpace(rctx.Str("description"))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// executeRoleList compensates for Miaoda environments that accept the name
|
||||
// query parameter but ignore it. A name lookup scans the complete server-side
|
||||
// result set, applies exact matching locally, and then applies the CLI's
|
||||
// offset/limit contract to the filtered result.
|
||||
func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
name, _ := params["name"].(string)
|
||||
if name == "" {
|
||||
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeRoleListData(data, params)
|
||||
}
|
||||
|
||||
requestedLimit := roleIntValue(params["limit"])
|
||||
requestedOffset := roleIntValue(params["offset"])
|
||||
allMatches := make([]interface{}, 0, requestedLimit)
|
||||
var firstPage map[string]interface{}
|
||||
seenRoleIDs := map[string]struct{}{}
|
||||
seenPageSignatures := map[string]struct{}{}
|
||||
expectedTotal := -1
|
||||
scannedRoleCount := 0
|
||||
|
||||
for page := 0; ; page++ {
|
||||
if page >= maxRoleListScanPages {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list exceeded %d pages while filtering by name",
|
||||
maxRoleListScanPages,
|
||||
).WithHint("retry without --name and paginate using the returned page_token")
|
||||
}
|
||||
|
||||
scanParams := roleListRequestParams(params, page)
|
||||
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if firstPage == nil {
|
||||
firstPage = data
|
||||
}
|
||||
items, hasMore, total, err := parseRoleListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedTotal < 0 {
|
||||
expectedTotal = total
|
||||
} else if total != expectedTotal {
|
||||
return nil, roleListProgressError("role list total changed across pages while filtering by name")
|
||||
}
|
||||
if scannedRoleCount+len(items) > expectedTotal {
|
||||
return nil, roleListProgressError("role list returned more roles than its total while filtering by name")
|
||||
}
|
||||
scannedRoleCount += len(items)
|
||||
if hasMore && scannedRoleCount >= expectedTotal {
|
||||
return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name")
|
||||
}
|
||||
if !hasMore && scannedRoleCount != expectedTotal {
|
||||
return nil, roleListProgressError("role list ended before returning its declared total while filtering by name")
|
||||
}
|
||||
signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if newRoleCount != len(items) {
|
||||
return nil, roleListProgressError("role list repeated roles across pages while filtering by name")
|
||||
}
|
||||
if _, duplicate := seenPageSignatures[signature]; duplicate {
|
||||
return nil, roleListProgressError("role list repeated a page while filtering by name")
|
||||
}
|
||||
seenPageSignatures[signature] = struct{}{}
|
||||
if hasMore && (len(items) == 0 || newRoleCount == 0) {
|
||||
return nil, roleListProgressError("role list reported more pages without returning new roles")
|
||||
}
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if ok && common.GetString(role, "name") == name {
|
||||
allMatches = append(allMatches, item)
|
||||
}
|
||||
}
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if firstPage == nil {
|
||||
firstPage = map[string]interface{}{}
|
||||
}
|
||||
return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil
|
||||
}
|
||||
|
||||
func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
start := offset
|
||||
if start > len(matches) {
|
||||
start = len(matches)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(matches) {
|
||||
end = len(matches)
|
||||
}
|
||||
hasMore := end < len(matches)
|
||||
items := append([]interface{}(nil), matches[start:end]...)
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out["items"] = items
|
||||
out["has_more"] = hasMore
|
||||
out["page_token"] = roleNextPageToken(start, limit, hasMore)
|
||||
out["total"] = len(matches)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
items, hasMore, total, err := parseRoleListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
limit := roleIntValue(params["limit"])
|
||||
offset := roleIntValue(params["offset"])
|
||||
|
||||
out["items"] = items
|
||||
out["has_more"] = hasMore
|
||||
out["page_token"] = roleNextPageToken(offset, limit, hasMore)
|
||||
out["total"] = total
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) {
|
||||
rawItems, hasItems := data["items"]
|
||||
items, ok := rawItems.([]interface{})
|
||||
if !hasItems || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field items must be an array",
|
||||
).WithHint("retry the read; do not treat a missing or malformed role list as empty")
|
||||
}
|
||||
if err := validateRoleCollection(items, "role list response field items"); err != nil {
|
||||
return nil, false, 0, err
|
||||
}
|
||||
rawHasMore, hasHasMore := data["has_more"]
|
||||
hasMore, ok := rawHasMore.(bool)
|
||||
if !hasHasMore || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field has_more must be a boolean",
|
||||
).WithHint("retry the read; pagination is incomplete without a valid has_more value")
|
||||
}
|
||||
total, ok := nonNegativeRoleInteger(data["total"])
|
||||
if _, exists := data["total"]; !exists || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field total must be a non-negative integer",
|
||||
).WithHint("retry the read; do not infer a role count from a missing or malformed total value")
|
||||
}
|
||||
return items, hasMore, total, nil
|
||||
}
|
||||
|
||||
func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) {
|
||||
roleIDs := make([]string, 0, len(items))
|
||||
newRoleCount := 0
|
||||
for index, item := range items {
|
||||
_, roleID, err := roleCollectionItem(item, "role list response field items", index)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
roleIDs = append(roleIDs, roleID)
|
||||
if _, seen := seenRoleIDs[roleID]; !seen {
|
||||
seenRoleIDs[roleID] = struct{}{}
|
||||
newRoleCount++
|
||||
}
|
||||
}
|
||||
return strings.Join(roleIDs, "\x00"), newRoleCount, nil
|
||||
}
|
||||
|
||||
func nonNegativeRoleInteger(value interface{}) (int, bool) {
|
||||
maxInt := uint64(^uint(0) >> 1)
|
||||
toInt := func(value int64) (int, bool) {
|
||||
if value < 0 || uint64(value) > maxInt {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
case int64:
|
||||
return toInt(value)
|
||||
case float64:
|
||||
maxIntExclusive := math.Ldexp(1, strconv.IntSize-1)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
case json.Number:
|
||||
parsed, err := value.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return toInt(parsed)
|
||||
case string:
|
||||
if value == "" || strings.IndexFunc(value, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
}) >= 0 {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
|
||||
if err != nil || parsed > maxInt {
|
||||
return 0, false
|
||||
}
|
||||
return int(parsed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func roleListProgressError(message string) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message).
|
||||
WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan")
|
||||
}
|
||||
|
||||
func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, invalidRoleDeleteResponse("role delete response data must be an object")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return map[string]interface{}{
|
||||
"role_id": requestedRoleID,
|
||||
"deleted": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
rawRoleID, ok := out["role_id"]
|
||||
if !ok {
|
||||
return nil, invalidRoleDeleteResponse("role delete response is missing role_id")
|
||||
}
|
||||
actualRoleID, stringOK := rawRoleID.(string)
|
||||
if !stringOK || actualRoleID != requestedRoleID {
|
||||
return nil, invalidRoleDeleteResponse(
|
||||
"role delete response role_id does not match requested role_id %q",
|
||||
requestedRoleID,
|
||||
)
|
||||
}
|
||||
rawDeleted, ok := out["deleted"]
|
||||
if !ok {
|
||||
return nil, invalidRoleDeleteResponse("role delete response is missing deleted")
|
||||
}
|
||||
deleted, boolOK := rawDeleted.(bool)
|
||||
if !boolOK || !deleted {
|
||||
return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type roleResponseData struct {
|
||||
RoleID string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
|
||||
return parseRoleResponseData(data, expectedRoleID, true)
|
||||
}
|
||||
|
||||
func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
|
||||
return parseRoleResponseData(data, expectedRoleID, false)
|
||||
}
|
||||
|
||||
func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) {
|
||||
if data == nil {
|
||||
return roleResponseData{}, invalidRoleResponse("role response data must be an object")
|
||||
}
|
||||
rawRole, exists := data["role"]
|
||||
role, ok := rawRole.(map[string]interface{})
|
||||
if !exists || !ok || role == nil {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role must be an object")
|
||||
}
|
||||
rawRoleID, exists := role["role_id"]
|
||||
roleID, ok := rawRoleID.(string)
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if !exists || !ok || roleID == "" {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string")
|
||||
}
|
||||
if expectedRoleID != "" && roleID != expectedRoleID {
|
||||
return roleResponseData{}, invalidRoleResponse(
|
||||
"role response role_id %q does not match requested role_id %q",
|
||||
roleID,
|
||||
expectedRoleID,
|
||||
)
|
||||
}
|
||||
rawName, nameExists := role["name"]
|
||||
name, nameOK := rawName.(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if requireName && !nameExists {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
|
||||
}
|
||||
if nameExists && (!nameOK || name == "") {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
|
||||
}
|
||||
rawDescription, descriptionExists := role["description"]
|
||||
description, descriptionOK := rawDescription.(string)
|
||||
if descriptionExists && !descriptionOK {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string")
|
||||
}
|
||||
return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil
|
||||
}
|
||||
|
||||
func invalidRoleResponse(message string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
|
||||
WithHint("retry the role read; do not treat a missing or malformed role as a successful result")
|
||||
}
|
||||
|
||||
func invalidRoleDeleteResponse(message string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
|
||||
WithHint("do not claim deletion; verify the target role with +role-list --name <exact_name>")
|
||||
}
|
||||
|
||||
func roleIntValue(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
i, err := strconv.Atoi(v.String())
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
case string:
|
||||
i, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func renderRoleCreatePretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID))
|
||||
}
|
||||
|
||||
func renderRoleGetPretty(w io.Writer, role roleResponseData) {
|
||||
renderRoleDetailPretty(w, role)
|
||||
}
|
||||
|
||||
func renderRoleUpdatePretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID))
|
||||
}
|
||||
|
||||
func renderRoleDeletePretty(w io.Writer, roleID string) {
|
||||
fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID))
|
||||
}
|
||||
|
||||
func renderRoleDetailPretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID))
|
||||
fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name))
|
||||
fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description))
|
||||
}
|
||||
|
||||
func renderRoleListPretty(w io.Writer, items []interface{}) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
|
||||
roleDisplayValue(common.GetString(role, "name")),
|
||||
roleDisplayValue(common.GetString(role, "description")))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
490
shortcuts/apps/apps_role_common.go
Normal file
490
shortcuts/apps/apps_role_common.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
roleListPath = apiBasePath + "/apps/%s/roles"
|
||||
roleItemPath = apiBasePath + "/apps/%s/roles/%s"
|
||||
roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list"
|
||||
roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add"
|
||||
roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove"
|
||||
roleMatchListPath = apiBasePath + "/apps/%s/user_role_list"
|
||||
defaultRolePageSize = 20
|
||||
maxRolePageSize = 100
|
||||
maxRoleMembers = 100
|
||||
|
||||
roleErrInvalidParameters = 3340001
|
||||
roleErrUserLimitExceeded = 3344027
|
||||
roleErrDepartmentLimitExceeded = 3344028
|
||||
roleErrChatLimitExceeded = 3344029
|
||||
roleErrAdminRequired = 3344030
|
||||
roleErrManagerRequired = 3344031
|
||||
roleErrInvalidRoleID = 3344034
|
||||
roleErrRoleNotFound = 3344035
|
||||
roleErrRoleAlreadyExists = 3344036
|
||||
roleErrRoleLimitExceeded = 3344037
|
||||
roleErrInvalidRoleName = 3344038
|
||||
roleErrInvalidRoleDescription = 3344039
|
||||
roleErrUnsupportedMemberType = 3344040
|
||||
roleErrInvalidMemberID = 3344041
|
||||
)
|
||||
|
||||
var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
const (
|
||||
roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`"
|
||||
roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and use the unique returned role_id"
|
||||
roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID"
|
||||
roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values"
|
||||
roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually"
|
||||
|
||||
roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)"
|
||||
roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)"
|
||||
roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)"
|
||||
)
|
||||
|
||||
type roleErrorOperation uint8
|
||||
|
||||
const (
|
||||
roleOperationList roleErrorOperation = iota
|
||||
roleOperationGet
|
||||
roleOperationCreate
|
||||
roleOperationUpdate
|
||||
roleOperationDelete
|
||||
roleOperationMemberList
|
||||
roleOperationMemberAdd
|
||||
roleOperationMemberRemove
|
||||
roleOperationMatchList
|
||||
)
|
||||
|
||||
type roleMemberGroups struct {
|
||||
Users []string `json:"users"`
|
||||
Departments []string `json:"departments"`
|
||||
Chats []string `json:"chats"`
|
||||
}
|
||||
|
||||
type roleMemberKind struct {
|
||||
memberType string
|
||||
dataKey string
|
||||
flagName string
|
||||
prefix string
|
||||
}
|
||||
|
||||
var roleMemberKinds = []roleMemberKind{
|
||||
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
|
||||
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
|
||||
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
|
||||
}
|
||||
|
||||
func roleAppID(rctx *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(rctx.Str("app-id"))
|
||||
}
|
||||
|
||||
func roleID(rctx *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(rctx.Str("role-id"))
|
||||
}
|
||||
|
||||
func validateRoleAppID(rctx *common.RuntimeContext) error {
|
||||
appID := roleAppID(rctx)
|
||||
if appID == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required").
|
||||
WithHint("list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
if strings.HasPrefix(appID, "cli_") {
|
||||
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a Lark app_id").
|
||||
WithHint("pass the app_... value from `lark-cli apps +list`, not the cli_... credential app id")
|
||||
}
|
||||
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
|
||||
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id starting with app_").
|
||||
WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id")
|
||||
}
|
||||
// app-id must not contain forward slashes (apps are identified by app_xxx IDs).
|
||||
for _, r := range appID {
|
||||
if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters")
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: block path traversal and URL metacharacters.
|
||||
if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoleID(rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
roleID := roleID(rctx)
|
||||
if roleID == "" {
|
||||
return appsValidationParamError("--role-id", "--role-id is required").
|
||||
WithHint("list roles with `lark-cli apps +role-list --app-id <app_id>`")
|
||||
}
|
||||
return validateExistingRoleIDValue(roleID)
|
||||
}
|
||||
|
||||
// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL
|
||||
// metacharacters (? # %) in values interpolated into a URL path, providing
|
||||
// defense-in-depth alongside validate.EncodePathSegment.
|
||||
func validateRolePathSegmentSafe(value, flagName string) error {
|
||||
for _, seg := range strings.Split(value, "/") {
|
||||
if seg == ".." {
|
||||
return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName).
|
||||
WithHint("provide a valid %s without path traversal", flagName)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(value, "?#%") {
|
||||
return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName).
|
||||
WithHint("provide a valid %s without URL metacharacters", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalRoleID(roleID string) error {
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if roleID == "" {
|
||||
return nil
|
||||
}
|
||||
return validateCreateRoleIDValue(roleID)
|
||||
}
|
||||
|
||||
func validateCreateRoleIDValue(roleID string) error {
|
||||
if !optionalRoleIDPattern.MatchString(roleID) {
|
||||
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
|
||||
WithHint("omit --role-id to let the server generate one")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExistingRoleIDValue(roleID string) error {
|
||||
if !optionalRoleIDPattern.MatchString(roleID) {
|
||||
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
|
||||
WithHint("resolve the role with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and pass its role_id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
limit := defaultRolePageSize
|
||||
if rctx.Changed("page-size") {
|
||||
limit = rctx.Int("page-size")
|
||||
}
|
||||
if limit < 1 || limit > maxRolePageSize {
|
||||
return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize).
|
||||
WithHint("use --page-size between 1 and 100")
|
||||
}
|
||||
|
||||
offset := 0
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
if pageToken != "" {
|
||||
parsedOffset, err := strconv.Atoi(pageToken)
|
||||
if err != nil || parsedOffset < 0 {
|
||||
return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset").
|
||||
WithHint("reuse page_token from the previous +role-list response")
|
||||
}
|
||||
offset = parsedOffset
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roleNextPageToken(offset, limit int, hasMore bool) string {
|
||||
if !hasMore {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(offset + limit)
|
||||
}
|
||||
|
||||
func splitRoleMemberCSV(s, flagName string) ([]string, error) {
|
||||
parts := strings.Split(s, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
// Reject values containing whitespace, control characters, or URL metacharacters
|
||||
// (member IDs are open_id/open_department_id/open_chat_id which are safe tokens).
|
||||
if err := validateMemberID(value, flagName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// validateMemberID rejects values containing characters that are invalid in
|
||||
// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters).
|
||||
func validateMemberID(value, flagName string) error {
|
||||
if err := validateMemberIDPrefix(value, flagName); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters").
|
||||
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces")
|
||||
}
|
||||
if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' {
|
||||
return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)").
|
||||
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMemberIDPrefix(value, flagName string) error {
|
||||
kind, ok := roleMemberKindForFlag(flagName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
|
||||
return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix).
|
||||
WithHint("resolve names or emails to open IDs before calling role member commands")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) {
|
||||
if flagName == "--user-id" {
|
||||
flagName = "--users"
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.flagName == flagName {
|
||||
return kind, true
|
||||
}
|
||||
}
|
||||
return roleMemberKind{}, false
|
||||
}
|
||||
|
||||
func roleMemberKindForType(memberType string) (roleMemberKind, bool) {
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.memberType == memberType {
|
||||
return kind, true
|
||||
}
|
||||
}
|
||||
return roleMemberKind{}, false
|
||||
}
|
||||
|
||||
func roleDisplayValue(value string) string {
|
||||
value = validate.SanitizeForTerminal(value)
|
||||
value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// withRoleErrorHint refines documented Spark role errors with command-specific
|
||||
// recovery while preserving the typed error, numeric code, log_id, and any
|
||||
// server-provided detail. Unknown codes retain the existing Apps fallback.
|
||||
func withRoleErrorHint(err error, operation roleErrorOperation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
hint := roleErrorHint(problem.Code, operation)
|
||||
if hint == "" {
|
||||
return withAppsHint(err, roleFallbackHint(operation))
|
||||
}
|
||||
|
||||
existing := strings.TrimSpace(problem.Hint)
|
||||
canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype))
|
||||
switch {
|
||||
case existing == "", existing == canonicalAPIHint:
|
||||
problem.Hint = hint
|
||||
case !strings.Contains(existing, hint):
|
||||
problem.Hint = existing + "; " + hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func roleFallbackHint(operation roleErrorOperation) string {
|
||||
switch operation {
|
||||
case roleOperationList:
|
||||
return roleAppHint
|
||||
case roleOperationCreate:
|
||||
return roleCreateHint
|
||||
case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove:
|
||||
return roleMemberHint
|
||||
case roleOperationMatchList:
|
||||
return roleMatchHint
|
||||
default:
|
||||
return roleItemHint
|
||||
}
|
||||
}
|
||||
|
||||
func roleErrorHint(code int, operation roleErrorOperation) string {
|
||||
switch code {
|
||||
case roleErrInvalidParameters:
|
||||
return roleFallbackHint(operation)
|
||||
case roleErrAdminRequired:
|
||||
return "ask an app administrator to perform this operation or grant the calling user app-administrator access"
|
||||
case roleErrManagerRequired:
|
||||
return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access"
|
||||
case roleErrInvalidRoleID:
|
||||
if operation == roleOperationCreate {
|
||||
return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service"
|
||||
}
|
||||
case roleErrRoleNotFound:
|
||||
if operation == roleOperationMatchList {
|
||||
return "list the app's current roles and retry; role data used for this match may no longer be valid"
|
||||
}
|
||||
return roleItemHint
|
||||
case roleErrRoleAlreadyExists:
|
||||
if operation == roleOperationCreate {
|
||||
return "choose a different --role-id or omit --role-id to let the server generate one"
|
||||
}
|
||||
case roleErrRoleLimitExceeded:
|
||||
if operation == roleOperationCreate {
|
||||
return "delete an unused app role before creating another role"
|
||||
}
|
||||
case roleErrInvalidRoleName:
|
||||
if operation == roleOperationCreate || operation == roleOperationUpdate {
|
||||
return "adjust --name to a non-empty value accepted by the role service"
|
||||
}
|
||||
case roleErrInvalidRoleDescription:
|
||||
if operation == roleOperationCreate || operation == roleOperationUpdate {
|
||||
return "adjust --description to a value accepted by the role service"
|
||||
}
|
||||
case roleErrUnsupportedMemberType:
|
||||
if operation == roleOperationMemberList {
|
||||
return "use --member-type user, department, or chat, or omit --member-type to list all member types"
|
||||
}
|
||||
case roleErrInvalidMemberID:
|
||||
if operation == roleOperationMatchList {
|
||||
return "resolve the target user to an open_id and retry with --user-id <open_id>"
|
||||
}
|
||||
if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove {
|
||||
return roleMemberHint
|
||||
}
|
||||
case roleErrUserLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the users being added with --users, or remove unused user members before retrying"
|
||||
}
|
||||
case roleErrDepartmentLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the departments being added with --departments, or remove unused department members before retrying"
|
||||
}
|
||||
case roleErrChatLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the chats being added with --chats, or remove unused chat members before retrying"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index)
|
||||
}
|
||||
rawRoleID, exists := role["role_id"]
|
||||
roleID, stringOK := rawRoleID.(string)
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if !exists || !stringOK || roleID == "" {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index)
|
||||
}
|
||||
rawName, exists := role["name"]
|
||||
name, stringOK := rawName.(string)
|
||||
if !exists || !stringOK || strings.TrimSpace(name) == "" {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index)
|
||||
}
|
||||
return role, roleID, nil
|
||||
}
|
||||
|
||||
func validateRoleCollection(items []interface{}, collection string) error {
|
||||
for index, item := range items {
|
||||
if _, _, err := roleCollectionItem(item, collection, index); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidRoleCollectionResponse(format string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
|
||||
WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result")
|
||||
}
|
||||
|
||||
func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) {
|
||||
users, err := splitRoleMemberCSV(usersCSV, "--users")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
departments, err := splitRoleMemberCSV(departmentsCSV, "--departments")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
chats, err := splitRoleMemberCSV(chatsCSV, "--chats")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
groups := roleMemberGroups{
|
||||
Users: users,
|
||||
Departments: departments,
|
||||
Chats: chats,
|
||||
}
|
||||
total := len(groups.Users) + len(groups.Departments) + len(groups.Chats)
|
||||
if total == 0 {
|
||||
reason := "provide at least one of --users, --departments, or --chats"
|
||||
return groups, appsValidationError("at least one of --users, --departments, or --chats is required").
|
||||
WithParams(
|
||||
appsInvalidParam("--users", reason),
|
||||
appsInvalidParam("--departments", reason),
|
||||
appsInvalidParam("--chats", reason),
|
||||
).
|
||||
WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id")
|
||||
}
|
||||
if total > maxRoleMembers {
|
||||
return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers).
|
||||
WithParams(roleMemberLimitParams(groups)...).
|
||||
WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers))
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if len(groups.Users) > 0 {
|
||||
body["users"] = groups.Users
|
||||
}
|
||||
if len(groups.Departments) > 0 {
|
||||
body["departments"] = groups.Departments
|
||||
}
|
||||
if len(groups.Chats) > 0 {
|
||||
body["chats"] = groups.Chats
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam {
|
||||
reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers)
|
||||
params := make([]errs.InvalidParam, 0, len(roleMemberKinds))
|
||||
if len(groups.Users) > 0 {
|
||||
params = append(params, appsInvalidParam("--users", reason))
|
||||
}
|
||||
if len(groups.Departments) > 0 {
|
||||
params = append(params, appsInvalidParam("--departments", reason))
|
||||
}
|
||||
if len(groups.Chats) > 0 {
|
||||
params = append(params, appsInvalidParam("--chats", reason))
|
||||
}
|
||||
return params
|
||||
}
|
||||
447
shortcuts/apps/apps_role_common_test.go
Normal file
447
shortcuts/apps/apps_role_common_test.go
Normal file
@@ -0,0 +1,447 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "test-role"}
|
||||
cmd.SetContext(context.Background())
|
||||
for name, typ := range flagDefs {
|
||||
switch typ {
|
||||
case "bool":
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
case "int":
|
||||
cmd.Flags().Int(name, 0, "")
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
default:
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
}
|
||||
for name, val := range flags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("set flag %q = %q: %v", name, val, err)
|
||||
}
|
||||
}
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("err = %#v, want validation error", err)
|
||||
}
|
||||
if validation.Param != param {
|
||||
t.Fatalf("param = %q, want %s", validation.Param, param)
|
||||
}
|
||||
return problem
|
||||
}
|
||||
|
||||
func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, want validation/invalid_argument", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("err = %#v, want validation error", err)
|
||||
}
|
||||
if validation.Param != "" {
|
||||
t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param)
|
||||
}
|
||||
if len(validation.Params) != len(params) {
|
||||
t.Fatalf("params = %#v, want %v", validation.Params, params)
|
||||
}
|
||||
for index, want := range params {
|
||||
if validation.Params[index].Name != want || validation.Params[index].Reason == "" {
|
||||
t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want)
|
||||
}
|
||||
}
|
||||
return problem
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{})
|
||||
params, err := buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRolePageParams() = %v", err)
|
||||
}
|
||||
if params["limit"] != defaultRolePageSize || params["offset"] != 0 {
|
||||
t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize)
|
||||
}
|
||||
|
||||
rctx, _, _ = newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-size": "20", "page-token": "40"})
|
||||
params, err = buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRolePageParams(changed) = %v", err)
|
||||
}
|
||||
if params["limit"] != 20 || params["offset"] != 40 {
|
||||
t.Fatalf("params = %#v, want limit=20 offset=40", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-token": "abc"})
|
||||
_, err := buildRolePageParams(rctx)
|
||||
assertRoleValidationParam(t, err, "--page-token")
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-size": "101"})
|
||||
_, err := buildRolePageParams(rctx)
|
||||
assertRoleValidationParam(t, err, "--page-size")
|
||||
}
|
||||
|
||||
func TestValidateOptionalRoleID(t *testing.T) {
|
||||
for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} {
|
||||
if err := validateOptionalRoleID(good); err != nil {
|
||||
t.Fatalf("validateOptionalRoleID(%q) = %v", good, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} {
|
||||
err := validateOptionalRoleID(bad)
|
||||
problem := assertRoleValidationParam(t, err, "--role-id")
|
||||
if !strings.Contains(problem.Hint, "omit --role-id") {
|
||||
t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleFlagHelpersTrim(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "})
|
||||
if got := roleAppID(rctx); got != "app_1" {
|
||||
t.Fatalf("roleAppID() = %q, want app_1", got)
|
||||
}
|
||||
if got := roleID(rctx); got != "role_1" {
|
||||
t.Fatalf("roleID() = %q, want role_1", got)
|
||||
}
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
t.Fatalf("validateRoleID() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsEmpty(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{})
|
||||
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
if problem.Message != "--app-id is required" {
|
||||
t.Fatalf("message = %q, want --app-id is required", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("hint is empty, want recovery guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) {
|
||||
for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": appID})
|
||||
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": "cli_app"})
|
||||
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) {
|
||||
for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": appID})
|
||||
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
if !strings.Contains(problem.Message, "starting with app_") {
|
||||
t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": "app_x", "role-id": "bad/role"})
|
||||
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
|
||||
if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") {
|
||||
t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": "app_x"})
|
||||
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
|
||||
if problem.Message != "--role-id is required" {
|
||||
t.Fatalf("message = %q, want --role-id is required", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsAndBody(t *testing.T) {
|
||||
groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRoleMemberGroups() = %v", err)
|
||||
}
|
||||
if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
body := buildRoleMemberBody(groups)
|
||||
assertJSONEquivalent(t, body, map[string]interface{}{
|
||||
"users": []interface{}{"ou_a", "ou_b"},
|
||||
"departments": []interface{}{"od-a"},
|
||||
"chats": []interface{}{"oc_a"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) {
|
||||
_, err := buildRoleMemberGroups(" , ", "", "")
|
||||
assertRoleValidationParams(t, err, "--users", "--departments", "--chats")
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
users string
|
||||
departments string
|
||||
chats string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "users slash", users: "ou/bad", wantParam: "--users"},
|
||||
{name: "users email", users: "alice@example.com", wantParam: "--users"},
|
||||
{name: "users wrong prefix", users: "user_123", wantParam: "--users"},
|
||||
{name: "users prefix only", users: "ou_", wantParam: "--users"},
|
||||
{name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"},
|
||||
{name: "departments prefix only", departments: "od-", wantParam: "--departments"},
|
||||
{name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"},
|
||||
{name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"},
|
||||
{name: "chats prefix only", chats: "oc_", wantParam: "--chats"},
|
||||
{
|
||||
name: "departments",
|
||||
departments: "od-bad value",
|
||||
wantParam: "--departments",
|
||||
},
|
||||
{
|
||||
name: "chats",
|
||||
chats: "oc?bad",
|
||||
wantParam: "--chats",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats)
|
||||
assertRoleValidationParam(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) {
|
||||
users := make([]string, maxRoleMembers+1)
|
||||
for i := range users {
|
||||
users[i] = "ou_test"
|
||||
}
|
||||
_, err := buildRoleMemberGroups(strings.Join(users, ","), "", "")
|
||||
assertRoleValidationParams(t, err, "--users")
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) {
|
||||
chats := make([]string, maxRoleMembers+1)
|
||||
for i := range chats {
|
||||
chats[i] = "oc_test"
|
||||
}
|
||||
_, err := buildRoleMemberGroups("", "", strings.Join(chats, ","))
|
||||
problem := assertRoleValidationParams(t, err, "--chats")
|
||||
if !strings.Contains(problem.Message, "role members cannot exceed 100") {
|
||||
t.Fatalf("message = %q, want role members limit", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") {
|
||||
t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) {
|
||||
users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",")
|
||||
chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",")
|
||||
_, err := buildRoleMemberGroups(users, "", chats)
|
||||
assertRoleValidationParams(t, err, "--users", "--chats")
|
||||
}
|
||||
|
||||
func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) {
|
||||
want := []roleMemberKind{
|
||||
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
|
||||
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
|
||||
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
|
||||
}
|
||||
if len(roleMemberKinds) != len(want) {
|
||||
t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want)
|
||||
}
|
||||
for index := range want {
|
||||
if roleMemberKinds[index] != want[index] {
|
||||
t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) {
|
||||
got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ")
|
||||
if got != "Admin red value" {
|
||||
t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleNextPageToken(t *testing.T) {
|
||||
if got := roleNextPageToken(40, 20, true); got != "60" {
|
||||
t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got)
|
||||
}
|
||||
if got := roleNextPageToken(40, 20, false); got != "" {
|
||||
t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
operation roleErrorOperation
|
||||
wantHint string
|
||||
forbid string
|
||||
}{
|
||||
{name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint},
|
||||
{name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"},
|
||||
{name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"},
|
||||
{name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"},
|
||||
{name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"},
|
||||
{name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"},
|
||||
{name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"},
|
||||
{name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"},
|
||||
{name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"},
|
||||
{name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"},
|
||||
{name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"},
|
||||
{name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"},
|
||||
{name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"},
|
||||
{name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"},
|
||||
{name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"},
|
||||
{name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errclass.BuildAPIError(map[string]any{
|
||||
"code": tt.code,
|
||||
"msg": "role request failed",
|
||||
"log_id": "log-role-hint",
|
||||
}, errclass.ClassifyContext{Identity: "user"})
|
||||
err = withRoleErrorHint(err, tt.operation)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable {
|
||||
t.Fatalf("problem envelope changed: %+v", problem)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint)
|
||||
}
|
||||
if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) {
|
||||
t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) {
|
||||
err := errclass.BuildAPIError(map[string]any{
|
||||
"code": roleErrInvalidRoleName,
|
||||
"msg": "invalid role name",
|
||||
"error": map[string]any{
|
||||
"details": []any{map[string]any{"value": "name exceeds the service limit"}},
|
||||
},
|
||||
}, errclass.ClassifyContext{Identity: "user"})
|
||||
err = withRoleErrorHint(err, roleOperationCreate)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
for _, want := range []string{"name exceeds the service limit", "adjust --name"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) {
|
||||
var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required").
|
||||
WithCode(roleErrAdminRequired).
|
||||
WithHint("server detail: only owners may change this app")
|
||||
err = withRoleErrorHint(err, roleOperationUpdate)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
for _, want := range []string{"server detail: only owners", "ask an app administrator"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
611
shortcuts/apps/apps_role_member.go
Normal file
611
shortcuts/apps/apps_role_member.go
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsRoleMemberList lists members of an app role.
|
||||
var AppsRoleMemberList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-list",
|
||||
Description: "List app role members",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>",
|
||||
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id> --member-type user",
|
||||
"When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response",
|
||||
"--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines",
|
||||
"--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleMemberListParams(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberListParams; error is impossible here.
|
||||
params, _ := buildRoleMemberListParams(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleMemberListURL(rctx)).
|
||||
Desc("List app role members").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
params, err := buildRoleMemberListParams(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil)
|
||||
memberType, _ := params["member_type"].(string)
|
||||
if shouldRetryRoleMemberListWithoutFilter(err, memberType) {
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.")
|
||||
data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberList)
|
||||
}
|
||||
data, err = normalizeRoleMemberListData(data, memberType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if memberType != "" {
|
||||
fmt.Fprintf(
|
||||
rctx.IO().ErrOut,
|
||||
"warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n",
|
||||
memberType,
|
||||
)
|
||||
}
|
||||
out := roleMemberListOutputData(rctx, data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMemberAdd adds members to an app role.
|
||||
var AppsRoleMemberAdd = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-add",
|
||||
Description: "Add app role members",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x",
|
||||
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x,ou_y --departments od-x --chats oc_x",
|
||||
"Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
|
||||
{Name: "departments", Desc: "comma-separated open_department_id values"},
|
||||
{Name: "chats", Desc: "comma-separated open_chat_id values"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberAddBody; error is impossible here.
|
||||
body, _, _ := buildRoleMemberAddBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMemberAddURL(rctx)).
|
||||
Desc("Add app role members").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, _, err := buildRoleMemberAddBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberAdd)
|
||||
}
|
||||
data, err = normalizeRoleMemberMutationData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleMemberMutationPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMemberRemove removes members from an app role.
|
||||
var AppsRoleMemberRemove = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-remove",
|
||||
Description: "Remove app role members",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --users ou_x --yes",
|
||||
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --all --yes",
|
||||
"When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target",
|
||||
"--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
|
||||
{Name: "departments", Desc: "comma-separated open_department_id values"},
|
||||
{Name: "chats", Desc: "comma-separated open_chat_id values"},
|
||||
{Name: "all", Type: "bool", Desc: "remove all members from the role"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := buildRoleMemberRemoveBody(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberRemoveBody; error is impossible here.
|
||||
body, _, _ := buildRoleMemberRemoveBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMemberRemoveURL(rctx)).
|
||||
Desc("Remove app role members").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, _, err := buildRoleMemberRemoveBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberRemove)
|
||||
}
|
||||
data, err = normalizeRoleMemberMutationData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleMemberMutationPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMatchList lists roles matching a user in an app.
|
||||
var AppsRoleMatchList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-match-list",
|
||||
Description: "List app roles matching a user",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-match-list --app-id <app_id> --user-id <user_open_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := roleMatchTargetUserID(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMatchListBody; error is impossible here.
|
||||
body, _ := buildRoleMatchListBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMatchListURL(rctx)).
|
||||
Desc("List app role matches").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, err := buildRoleMatchListBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMatchList)
|
||||
}
|
||||
out, err := normalizeRoleMatchListData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleMatchListPretty(w, common.GetSlice(out, "roles"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func roleMemberListURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberListPath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMemberAddURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberAddPath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMemberRemoveURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberRemovePath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMatchListURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx)))
|
||||
}
|
||||
|
||||
func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
params := map[string]interface{}{}
|
||||
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
|
||||
if _, ok := roleMemberKindForType(memberType); !ok {
|
||||
return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat").
|
||||
WithHint("omit --member-type to list all member types")
|
||||
}
|
||||
params["member_type"] = memberType
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool {
|
||||
if err == nil || memberType != "chat" {
|
||||
return false
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 {
|
||||
return true
|
||||
}
|
||||
return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type")
|
||||
}
|
||||
|
||||
func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response data must be an object",
|
||||
).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role")
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
// The role service uses an exact empty data object when the requested member
|
||||
// view is empty. For a filtered request, that proves only the selected group
|
||||
// is empty; non-selected groups must remain omitted rather than being
|
||||
// synthesized as empty.
|
||||
if len(data) == 0 {
|
||||
if memberType != "" {
|
||||
kind, _ := roleMemberKindForType(memberType)
|
||||
out[kind.dataKey] = []string{}
|
||||
return out, nil
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
out[kind.dataKey] = []string{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if memberType != "" {
|
||||
selectedKind, _ := roleMemberKindForType(memberType)
|
||||
values, err := parseRoleMemberIDs(data, selectedKind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.memberType != memberType {
|
||||
delete(out, kind.dataKey)
|
||||
}
|
||||
}
|
||||
out[selectedKind.dataKey] = values
|
||||
return out, nil
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
values, err := parseRoleMemberIDs(data, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind.dataKey] = values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) {
|
||||
raw, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response is missing %s",
|
||||
kind.dataKey,
|
||||
).WithHint("retry the member operation; do not treat a missing member group as empty")
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
if stringItems, stringOK := raw.([]string); stringOK {
|
||||
items = make([]interface{}, len(stringItems))
|
||||
for index, value := range stringItems {
|
||||
items[index] = value
|
||||
}
|
||||
} else {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response field %s must be an array of strings",
|
||||
kind.dataKey,
|
||||
).WithHint("retry the member operation; do not use malformed member data as a permission baseline")
|
||||
}
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := item.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response field %s contains an invalid ID at index %d",
|
||||
kind.dataKey,
|
||||
index,
|
||||
).WithHint("retry the member operation; expected open IDs with the documented member-type prefix")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for key, value := range data {
|
||||
out[key] = value
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if _, exists := data[kind.dataKey]; !exists {
|
||||
continue
|
||||
}
|
||||
values, err := parseRoleMemberIDs(data, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind.dataKey] = values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
|
||||
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
if err != nil {
|
||||
return nil, groups, err
|
||||
}
|
||||
return buildRoleMemberBody(groups), groups, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
|
||||
if rctx.Bool("all") {
|
||||
if hasExplicitRoleMemberFlags(rctx) {
|
||||
return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats").
|
||||
WithParams(roleMemberRemoveConflictParams(rctx)...).
|
||||
WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all")
|
||||
}
|
||||
return map[string]interface{}{"all": true}, roleMemberGroups{}, nil
|
||||
}
|
||||
if !hasExplicitRoleMemberFlags(rctx) {
|
||||
reason := "provide member IDs or use --all"
|
||||
return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member").
|
||||
WithParams(
|
||||
appsInvalidParam("--users", reason),
|
||||
appsInvalidParam("--departments", reason),
|
||||
appsInvalidParam("--chats", reason),
|
||||
appsInvalidParam("--all", reason),
|
||||
).
|
||||
WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members")
|
||||
}
|
||||
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
if err != nil {
|
||||
return nil, groups, err
|
||||
}
|
||||
return buildRoleMemberBody(groups), groups, nil
|
||||
}
|
||||
|
||||
func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam {
|
||||
reason := "cannot be combined with --all"
|
||||
params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" {
|
||||
params = append(params, appsInvalidParam(kind.flagName, reason))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(rctx.Str("users")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("departments")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("chats")) != ""
|
||||
}
|
||||
|
||||
func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
targetUserID, err := roleMatchTargetUserID(rctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"target_user_id": targetUserID}, nil
|
||||
}
|
||||
|
||||
func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("user-id"))
|
||||
if raw == "" {
|
||||
return "", appsValidationParamError("--user-id", "--user-id is required").
|
||||
WithHint("resolve the user to open_id first, then pass --user-id <open_id>")
|
||||
}
|
||||
if err := validateMemberID(raw, "--user-id"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} {
|
||||
switch rctx.Format {
|
||||
case "table", "csv", "ndjson":
|
||||
return roleMemberRows(data)
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func roleMemberRows(data map[string]interface{}) []interface{} {
|
||||
rows := []interface{}{}
|
||||
addRows := func(memberType string, values []string) {
|
||||
for _, value := range values {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"member_type": memberType,
|
||||
"member_id": value,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
addRows(kind.memberType, roleIDValues(data[kind.dataKey]))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
rawRoles, exists := data["roles"]
|
||||
roles, ok := rawRoles.([]interface{})
|
||||
if !exists || !ok {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role match response field roles must be an array",
|
||||
).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches")
|
||||
}
|
||||
if err := validateRoleCollection(roles, "role match response field roles"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
out["roles"] = roles
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
renderRoleMemberGroupsPretty(w, data)
|
||||
}
|
||||
|
||||
func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) {
|
||||
for _, kind := range roleMemberKinds {
|
||||
value, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) {
|
||||
renderedGroup := false
|
||||
for _, kind := range roleMemberKinds {
|
||||
value, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
|
||||
renderedGroup = true
|
||||
}
|
||||
if !renderedGroup {
|
||||
fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.")
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMemberSection(w io.Writer, label string, values []string) {
|
||||
if len(values) == 0 {
|
||||
fmt.Fprintf(w, "%s: []\n", label)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s:\n", label)
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(w, " - %s\n", roleDisplayValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
func roleIDValues(value interface{}) []string {
|
||||
switch items := value.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
v, ok := item.(string)
|
||||
if ok && strings.TrimSpace(v) != "" {
|
||||
out = append(out, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMatchListPretty(w io.Writer, items []interface{}) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
|
||||
roleDisplayValue(common.GetString(role, "name")),
|
||||
roleDisplayValue(common.GetString(role, "description")),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
1189
shortcuts/apps/apps_role_member_test.go
Normal file
1189
shortcuts/apps/apps_role_member_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1320
shortcuts/apps/apps_role_test.go
Normal file
1320
shortcuts/apps/apps_role_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,15 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsList,
|
||||
AppsAccessScopeSet,
|
||||
AppsAccessScopeGet,
|
||||
AppsRoleList,
|
||||
AppsRoleGet,
|
||||
AppsRoleCreate,
|
||||
AppsRoleUpdate,
|
||||
AppsRoleDelete,
|
||||
AppsRoleMemberList,
|
||||
AppsRoleMemberAdd,
|
||||
AppsRoleMemberRemove,
|
||||
AppsRoleMatchList,
|
||||
AppsHTMLPublish,
|
||||
AppsInit,
|
||||
AppsReleaseCreate,
|
||||
|
||||
@@ -21,11 +21,12 @@ import (
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)= 70。
|
||||
func TestAppsShortcuts_Returns70(t *testing.T) {
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 70 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 70", len(got))
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
|
||||
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+role-list": false,
|
||||
"+role-get": false,
|
||||
"+role-create": false,
|
||||
"+role-update": false,
|
||||
"+role-delete": false,
|
||||
"+role-member-list": false,
|
||||
"+role-member-add": false,
|
||||
"+role-member-remove": false,
|
||||
"+role-match-list": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
if sc.Hidden {
|
||||
t.Errorf("%s must be visible", sc.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("Shortcuts() missing %s", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
|
||||
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -676,6 +678,145 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_IllegalSortOrderType guards against a P1 where a
|
||||
// non-string sort.order (123 / null / false) was silently coerced to "asc" and
|
||||
// created a block with a tampered sort. A present-but-illegal order must now
|
||||
// surface a typed validation error, never a silent default.
|
||||
func TestBaseDashboardBlockCreate_IllegalSortOrderType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
order string // raw JSON literal for the order value
|
||||
}{
|
||||
{"number", "123"},
|
||||
{"null", "null"},
|
||||
{"bool", "false"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
dc := `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
|
||||
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"group","order":` + tc.order + `}}]}`
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "Bad", "--type", "column", "--data-config", dc}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for order=%s, got nil (stdout=%s)", tc.order, stdout.String())
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--data-config" {
|
||||
t.Fatalf("param=%q, want --data-config", ve.Param)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "sort.order") {
|
||||
t.Fatalf("error should name sort.order, got: %v", ve)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_MissingSortOrder pins the full create-path behavior
|
||||
// when sort.order is absent: group/view are normalized to order:"asc" and succeed
|
||||
// (matching the documented auto-fill), while value has no safe default and must
|
||||
// surface a typed validation error. These run end-to-end (Validate → normalize →
|
||||
// validate), so reverting the normalize/validate change flips a case and fails.
|
||||
func TestBaseDashboardBlockCreate_MissingSortOrder(t *testing.T) {
|
||||
dc := func(sortType string) string {
|
||||
return `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
|
||||
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"` + sortType + `"}}]}`
|
||||
}
|
||||
|
||||
// group / view: absent order is auto-filled with "asc" and the request goes through.
|
||||
for _, sortType := range []string{"group", "view"} {
|
||||
t.Run(sortType+" defaults to asc", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "OK", "--type", "column", "--data-config", dc(sortType),
|
||||
"--dry-run", "--format", "pretty"}
|
||||
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"order":"asc"`) {
|
||||
t.Fatalf("expected normalized order:asc for type=%s, stdout=%s", sortType, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// value: no meaningful default direction, so a missing order is a typed error.
|
||||
t.Run("value requires explicit order", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "Bad", "--type", "column", "--data-config", dc("value")}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for value sort missing order, got nil (stdout=%s)", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument problem, got %T %v", err, err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--data-config" {
|
||||
t.Fatalf("expected param --data-config, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "sort.order 缺失") {
|
||||
t.Fatalf("error should report missing order, got: %v", ve)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeDataConfigSortOrder pins the normalization contract for sort.order:
|
||||
// only a truly absent key gets the "asc" default; a present illegal value is left
|
||||
// untouched so validation can reject it; a valid string is lower-cased.
|
||||
func TestNormalizeDataConfigSortOrder(t *testing.T) {
|
||||
sortOf := func(cfg map[string]interface{}) map[string]interface{} {
|
||||
gb := cfg["group_by"].([]interface{})
|
||||
return gb[0].(map[string]interface{})["sort"].(map[string]interface{})
|
||||
}
|
||||
newCfg := func(sort map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"table_name": "T",
|
||||
"series": []interface{}{map[string]interface{}{"field_name": "v", "rollup": "sum"}},
|
||||
"group_by": []interface{}{map[string]interface{}{"field_name": "g", "sort": sort}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("absent order defaults to asc for group", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group"}))
|
||||
if got := sortOf(out)["order"]; got != "asc" {
|
||||
t.Fatalf("order=%v, want asc", got)
|
||||
}
|
||||
})
|
||||
t.Run("absent order not defaulted for value", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "value"}))
|
||||
if _, has := sortOf(out)["order"]; has {
|
||||
t.Fatalf("value sort must not get a defaulted order: %v", sortOf(out))
|
||||
}
|
||||
})
|
||||
t.Run("valid string lower-cased", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": "DESC"}))
|
||||
if got := sortOf(out)["order"]; got != "desc" {
|
||||
t.Fatalf("order=%v, want desc", got)
|
||||
}
|
||||
})
|
||||
t.Run("illegal number not coerced", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": float64(123)}))
|
||||
if got := sortOf(out)["order"]; got != float64(123) {
|
||||
t.Fatalf("order=%v (type %T), want untouched 123", got, got)
|
||||
}
|
||||
})
|
||||
t.Run("illegal nil not coerced", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "view", "order": nil}))
|
||||
got, has := sortOf(out)["order"]
|
||||
if !has || got != nil {
|
||||
t.Fatalf("order=%v has=%v, want present nil (untouched)", got, has)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Text Block Tests ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
|
||||
|
||||
@@ -117,6 +117,14 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listFieldNamesAliasRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "limit=20", "field_id=Name", "field_id=Age")
|
||||
|
||||
filteredListRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
|
||||
@@ -1296,6 +1296,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_alias"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name,Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1320,6 +1343,30 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json alias", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"field_id_list": []interface{}{"fld_name"},
|
||||
"record_id_list": []interface{}{"rec_alias"},
|
||||
"data": []interface{}{[]interface{}{"Carol"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) || !strings.Contains(got, `"rec_alias"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1576,6 +1623,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
|
||||
@@ -28,6 +28,14 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
@@ -35,6 +43,9 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
|
||||
for name := range stringArrayFlags {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
@@ -50,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, values := range stringSliceFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -545,6 +561,8 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
"not table_id or field_id",
|
||||
"dashboard-block-data-config.md as the SSOT",
|
||||
"do not invent data_config from natural language",
|
||||
"set the intended group_by.sort in the initial create request",
|
||||
"do not create first and then issue a second update",
|
||||
"sequentially",
|
||||
},
|
||||
},
|
||||
@@ -825,6 +843,7 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
"may use null for empty cells",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"do not immediately +record-list the same table",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
"lark-base-cell-value.md",
|
||||
|
||||
@@ -23,7 +23,7 @@ var BaseDashboardArrange = common.Shortcut{
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
|
||||
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard, or to tidy up a dashboard created from scratch in this session.",
|
||||
},
|
||||
DryRun: dryRunDashboardArrange,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
||||
@@ -35,6 +35,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
|
||||
"data_config uses table and field names, not table_id or field_id.",
|
||||
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
||||
"For funnel/stage charts backed by ordered helper data, set the intended group_by.sort in the initial create request; do not create first and then issue a second update just to fix sorting.",
|
||||
"Record the returned block_id; block update/delete/get-data commands need it.",
|
||||
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "dashboard-id", Desc: "hidden compatibility flag accepted by dashboard block commands; ignored by get-data", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||
|
||||
@@ -26,7 +26,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
||||
|
||||
@@ -1038,11 +1038,23 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||||
m["mode"] = strings.ToLower(strings.TrimSpace(md))
|
||||
}
|
||||
if sub, ok := m["sort"].(map[string]interface{}); ok {
|
||||
sortType := ""
|
||||
if t, ok := sub["type"].(string); ok {
|
||||
sub["type"] = strings.ToLower(strings.TrimSpace(t))
|
||||
sortType = strings.ToLower(strings.TrimSpace(t))
|
||||
sub["type"] = sortType
|
||||
}
|
||||
if o, ok := sub["order"].(string); ok {
|
||||
sub["order"] = strings.ToLower(strings.TrimSpace(o))
|
||||
// Only lowercase a string order; leave a present-but-non-string
|
||||
// order untouched so validateBlockDataConfig can reject it
|
||||
// instead of it being silently coerced below.
|
||||
_, hasOrderKey := sub["order"]
|
||||
orderStr, orderIsString := sub["order"].(string)
|
||||
if orderIsString {
|
||||
sub["order"] = strings.ToLower(strings.TrimSpace(orderStr))
|
||||
}
|
||||
// Default only when the order key is truly absent. A present
|
||||
// key (even an illegal type/value) must survive to validation.
|
||||
if !hasOrderKey && (sortType == "group" || sortType == "view") {
|
||||
sub["order"] = "asc"
|
||||
}
|
||||
m["sort"] = sub
|
||||
}
|
||||
@@ -1126,12 +1138,16 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
|
||||
if sub, ok := m["sort"].(map[string]interface{}); ok {
|
||||
t, _ := sub["type"].(string)
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
o, _ := sub["order"].(string)
|
||||
o = strings.ToLower(strings.TrimSpace(o))
|
||||
if t != "group" && t != "value" && t != "view" {
|
||||
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
|
||||
}
|
||||
if o != "asc" && o != "desc" {
|
||||
orderRaw, hasOrder := sub["order"]
|
||||
o, orderIsString := orderRaw.(string)
|
||||
o = strings.ToLower(strings.TrimSpace(o))
|
||||
switch {
|
||||
case !hasOrder:
|
||||
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 缺失;sort 存在时必须设置 order 为 asc 或 desc,例如 \"sort\":{\"type\":\"group\",\"order\":\"asc\"}", i))
|
||||
case !orderIsString || (o != "asc" && o != "desc"):
|
||||
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
|
||||
}
|
||||
}
|
||||
@@ -1178,5 +1194,5 @@ func formatDataConfigErrors(problems []string) error {
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")).WithParam("--data-config")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -21,6 +21,7 @@ var BaseRecordList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -43,6 +44,9 @@ var BaseRecordList = common.Shortcut{
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordListFieldAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,6 +79,15 @@ func recordListFieldRefFlag() common.Flag {
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListFieldNamesAliasFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "field-names",
|
||||
Type: "string_slice",
|
||||
Desc: "hidden alias for --field-id; accepts comma-separated field names",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
@@ -89,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("field-id") && runtime.Changed("field-names") {
|
||||
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -376,6 +376,9 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
}
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
|
||||
@@ -22,15 +22,23 @@ func GetString(m map[string]interface{}, keys ...string) string {
|
||||
|
||||
// GetFloat safely extracts a float64 (the default JSON number type).
|
||||
func GetFloat(m map[string]interface{}, keys ...string) float64 {
|
||||
f, _ := GetFloatOK(m, keys...)
|
||||
return f
|
||||
}
|
||||
|
||||
// GetFloatOK extracts a float64 and reports whether the field was present and
|
||||
// numeric. Use it for protocol discriminators where silently turning malformed
|
||||
// input into zero could misclassify a response as successful.
|
||||
func GetFloatOK(m map[string]interface{}, keys ...string) (float64, bool) {
|
||||
if len(keys) == 0 {
|
||||
return 0
|
||||
return 0, false
|
||||
}
|
||||
v := navigate(m, keys[:len(keys)-1])
|
||||
if v == nil {
|
||||
return 0
|
||||
return 0, false
|
||||
}
|
||||
f, _ := util.ToFloat64(v[keys[len(keys)-1]])
|
||||
return f
|
||||
f, ok := util.ToFloat64(v[keys[len(keys)-1]])
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
|
||||
|
||||
@@ -64,6 +64,24 @@ func TestGetFloat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatOKDistinguishesMalformedValuesFromZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := map[string]interface{}{
|
||||
"zero": float64(0),
|
||||
"null": nil,
|
||||
"string": "0",
|
||||
}
|
||||
if got, ok := GetFloatOK(m, "zero"); !ok || got != 0 {
|
||||
t.Fatalf("GetFloatOK(zero) = (%v, %t), want (0, true)", got, ok)
|
||||
}
|
||||
for _, key := range []string{"null", "string", "missing"} {
|
||||
if got, ok := GetFloatOK(m, key); ok || got != 0 {
|
||||
t.Fatalf("GetFloatOK(%s) = (%v, %t), want (0, false)", key, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInt(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"count": 42,
|
||||
|
||||
@@ -32,14 +32,15 @@ type driveDeleteSpec struct {
|
||||
FileType string
|
||||
}
|
||||
|
||||
// DriveDelete deletes a Drive file or folder and handles the async task
|
||||
// polling required by folder deletes.
|
||||
// DriveDelete deletes a Drive file or folder with async=true. When the response
|
||||
// includes a task_id, it performs a bounded task_check poll before returning a
|
||||
// resume command for unfinished tasks.
|
||||
var DriveDelete = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete",
|
||||
Description: "Delete a file or folder in Drive",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"space:document:delete"},
|
||||
Scopes: []string{"space:document:delete", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
|
||||
@@ -63,13 +64,11 @@ var DriveDelete = common.Shortcut{
|
||||
dry.DELETE("/open-apis/drive/v1/files/:file_token").
|
||||
Desc("[1] Delete file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"type": spec.FileType})
|
||||
Params(driveDeleteParams(spec))
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder delete)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async delete task status when task_id is returned").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
|
||||
return dry
|
||||
},
|
||||
@@ -84,56 +83,59 @@ var DriveDelete = common.Shortcut{
|
||||
data, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
|
||||
map[string]interface{}{"type": spec.FileType},
|
||||
driveDeleteParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"deleted": true,
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
"ready": ready,
|
||||
}
|
||||
if ready {
|
||||
out["deleted"] = true
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"deleted": true,
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Delete is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
"ready": ready,
|
||||
}
|
||||
if ready {
|
||||
out["deleted"] = true
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Delete task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func driveDeleteParams(spec driveDeleteSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": spec.FileType,
|
||||
"async": true,
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -32,16 +33,16 @@ func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +delete"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
if err := cmd.Flags().Set("file-token", "docx_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
@@ -71,14 +72,36 @@ func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Params["type"] != "folder" {
|
||||
if got.API[0].Params["type"] != "docx" {
|
||||
t.Fatalf("delete params = %#v", got.API[0].Params)
|
||||
}
|
||||
if got.API[0].Params["async"] != true {
|
||||
t.Fatalf("delete params = %#v, want async=true", got.API[0].Params)
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteScopesIncludeTaskCheckReadScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantScopes := map[string]bool{
|
||||
"space:document:delete": false,
|
||||
"drive:drive.metadata:readonly": false,
|
||||
}
|
||||
for _, scope := range DriveDelete.Scopes {
|
||||
if _, ok := wantScopes[scope]; ok {
|
||||
wantScopes[scope] = true
|
||||
}
|
||||
}
|
||||
for scope, seen := range wantScopes {
|
||||
if !seen {
|
||||
t.Fatalf("DriveDelete.Scopes missing %q: %#v", scope, DriveDelete.Scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteRequiresYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -97,6 +120,63 @@ func TestDriveDeleteRequiresYes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDriveDeleteFileSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_file_123"},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("type"); got != "file" {
|
||||
t.Errorf("delete query type=%q, want file", got)
|
||||
}
|
||||
if got := query.Get("async"); got != "true" {
|
||||
t.Errorf("delete query async=%q, want true", got)
|
||||
}
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("task_id"); got != "task_file_123" {
|
||||
t.Errorf("task_check task_id=%q, want task_file_123", got)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_file_123"`)) {
|
||||
t.Fatalf("stdout missing task_id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteWithoutTaskIDFallsBackToSyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
@@ -117,23 +197,33 @@ func TestDriveDeleteFileSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
for _, needle := range []string{
|
||||
`"deleted": true`,
|
||||
`"file_token": "file_token_test"`,
|
||||
`"type": "file"`,
|
||||
} {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
if bytes.Contains(stdout.Bytes(), []byte(`"task_id"`)) {
|
||||
t.Fatalf("stdout should not include task_id for sync success fallback: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileType string
|
||||
fileToken string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
name: "docx success",
|
||||
fileType: "docx",
|
||||
fileToken: "docx_src",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
@@ -145,7 +235,9 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
name: "folder timeout",
|
||||
fileType: "folder",
|
||||
fileToken: "fld_src",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "process"},
|
||||
@@ -157,15 +249,19 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
name: "folder failed",
|
||||
fileType: "folder",
|
||||
fileToken: "fld_src",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
wantErrContains: "folder task failed",
|
||||
wantErrContains: "drive task failed",
|
||||
},
|
||||
{
|
||||
name: "task_check error",
|
||||
name: "docx task_check error",
|
||||
fileType: "docx",
|
||||
fileToken: "docx_src",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
@@ -179,7 +275,7 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fld_src",
|
||||
URL: "/open-apis/drive/v1/files/" + tt.fileToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
@@ -195,8 +291,8 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--file-token", tt.fileToken,
|
||||
"--type", tt.fileType,
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
@@ -222,3 +318,66 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteTimedOutTaskCanBeResumedWithTaskResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fld_token_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_resume_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "process"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "fld_token_test",
|
||||
"--type", "folder",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected delete error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_resume_123 --as bot"`)) {
|
||||
t.Fatalf("stdout missing next_command: %s", stdout.String())
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_resume_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected task_result error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_resume_123"`)) {
|
||||
t.Fatalf("task_result stdout missing task_id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("task_result stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder move/delete operations.
|
||||
// /drive/v1/files/task_check for async Drive move/delete operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
@@ -74,7 +74,7 @@ func (s driveTaskCheckStatus) Ready() bool {
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
status := strings.TrimSpace(s.Status)
|
||||
// The shared task_check endpoint is reused by multiple async flows. Some
|
||||
// backends return "failed", while folder delete can return the shorter
|
||||
// backends return "failed", while delete can return the shorter
|
||||
// terminal state "fail".
|
||||
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
|
||||
}
|
||||
|
||||
// getDriveTaskCheckStatus fetches and validates the current state of an async
|
||||
// folder move or delete task.
|
||||
// Drive move or delete task.
|
||||
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
@@ -159,11 +159,11 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Drive task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "drive task failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,20 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// These are fixed backend wire values for the Wiki-to-Drive task. Keep
|
||||
// them unchanged even though the CLI scenario uses wiki_move_to_drive.
|
||||
wikiMoveToDriveTaskType = "move_wiki_to_docs"
|
||||
wikiMoveToDriveResultKey = "move_wiki_to_docs_result"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, folder move/delete, wiki move, and wiki delete-space flows.
|
||||
// by Drive import, export, file/folder move/delete, wiki move, wiki move-to-drive,
|
||||
// and wiki delete flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki move-to-drive, or wiki delete operations",
|
||||
Risk: "read",
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
@@ -28,22 +36,23 @@ var DriveTaskResult = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check and all wiki task scenarios)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, or wiki_delete_node", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
validScenarios := map[string]bool{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
"wiki_delete_space": true,
|
||||
"wiki_delete_node": true,
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
"wiki_move_to_drive": true,
|
||||
"wiki_delete_space": true,
|
||||
"wiki_delete_node": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -55,7 +64,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
|
||||
}
|
||||
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
case "task_check", "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
|
||||
}
|
||||
@@ -97,13 +106,18 @@ var DriveTaskResult = common.Shortcut{
|
||||
Params(map[string]interface{}{"token": fileToken})
|
||||
case "task_check":
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Desc("[1] Query Drive file/folder move/delete task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
case "wiki_move":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki move task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
case "wiki_move_to_drive":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki move-to-drive task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
|
||||
case "wiki_delete_space":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki delete-space task result").
|
||||
@@ -140,6 +154,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
case "wiki_move":
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
case "wiki_move_to_drive":
|
||||
result, err = queryWikiMoveToDriveTask(runtime, taskID)
|
||||
case "wiki_delete_space":
|
||||
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
|
||||
case "wiki_delete_node":
|
||||
@@ -209,7 +225,7 @@ func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryTaskCheck returns the normalized status of a folder move/delete task.
|
||||
// queryTaskCheck returns the normalized status of a Drive file/folder move/delete task.
|
||||
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
@@ -244,7 +260,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
case "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
@@ -486,6 +502,75 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
|
||||
out["has_child"] = common.GetBool(node, "has_child")
|
||||
}
|
||||
|
||||
// queryWikiMoveToDriveTask returns the normalized status and final Drive
|
||||
// resource fields for wiki +move-to-drive. The task endpoint uses a dedicated
|
||||
// result object with numeric status codes: 0 success, 1 processing, -1 failure.
|
||||
func queryWikiMoveToDriveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id").WithCause(err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
result := common.GetMap(task, wikiMoveToDriveResultKey)
|
||||
if result == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing %s", wikiMoveToDriveResultKey)
|
||||
}
|
||||
statusCode, ok := common.GetFloatOK(result, "status")
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response has missing or non-numeric %s.status", wikiMoveToDriveResultKey)
|
||||
}
|
||||
if statusCode != -1 && statusCode != 0 && statusCode != 1 {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"wiki task response has unsupported %s.status: %v",
|
||||
wikiMoveToDriveResultKey,
|
||||
statusCode,
|
||||
)
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
if resolvedTaskID == "" {
|
||||
resolvedTaskID = taskID
|
||||
}
|
||||
status := int(statusCode)
|
||||
statusMsg := strings.TrimSpace(common.GetString(result, "status_msg"))
|
||||
if statusMsg == "" {
|
||||
switch {
|
||||
case status == 0:
|
||||
statusMsg = "success"
|
||||
case status < 0:
|
||||
statusMsg = "failure"
|
||||
default:
|
||||
statusMsg = "processing"
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "wiki_move_to_drive",
|
||||
"task_id": resolvedTaskID,
|
||||
"ready": status == 0,
|
||||
"failed": status < 0,
|
||||
"status": status,
|
||||
"status_msg": statusMsg,
|
||||
"obj_token": common.GetString(result, "obj_token"),
|
||||
"obj_type": common.GetString(result, "obj_type"),
|
||||
"url": common.GetString(result, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryWikiDeleteSpaceTask returns the normalized status of an async wiki
|
||||
// delete-space task. The backend reports a single delete_space_result object
|
||||
// rather than the per-node array used by wiki move.
|
||||
|
||||
@@ -66,6 +66,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
{
|
||||
name: "wiki move to Drive missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "wiki_move_to_drive",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -426,13 +433,174 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiMoveToDriveIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_move_to_drive"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "raw-task-signature"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Params["task_type"] != "move_wiki_to_docs" {
|
||||
t.Fatalf("wiki move-to-drive dry run = %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveToDriveStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
statusMsg string
|
||||
wantReady bool
|
||||
wantFailed bool
|
||||
}{
|
||||
{name: "success", status: 0, statusMsg: "success", wantReady: true},
|
||||
{name: "processing fallback label", status: 1, wantReady: false},
|
||||
{name: "failure", status: -1, statusMsg: "failure", wantFailed: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// The external handler may omit task.task_id, so the
|
||||
// result must retain the signed request ID.
|
||||
"move_wiki_to_docs_result": map[string]interface{}{
|
||||
"status": tt.status,
|
||||
"status_msg": tt.statusMsg,
|
||||
"obj_token": "docxABC",
|
||||
"obj_type": "docx",
|
||||
"url": "https://example.feishu.cn/docx/docxABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move_to_drive",
|
||||
"--task-id", "raw-task-signature",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunDrive() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_move_to_drive" || data["task_id"] != "raw-task-signature" {
|
||||
t.Fatalf("unexpected envelope = %#v", data)
|
||||
}
|
||||
if data["ready"] != tt.wantReady || data["failed"] != tt.wantFailed {
|
||||
t.Fatalf("readiness fields = %#v", data)
|
||||
}
|
||||
if tt.statusMsg == "" && data["status_msg"] != "processing" {
|
||||
t.Fatalf("status_msg = %#v, want processing fallback", data["status_msg"])
|
||||
}
|
||||
if data["obj_token"] != "docxABC" || data["obj_type"] != "docx" || data["url"] == "" {
|
||||
t.Fatalf("result fields = %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveToDriveRejectsMissingResult(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task": map[string]interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move_to_drive",
|
||||
"--task-id", "raw-task-signature",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveToDriveRejectsMalformedStatus(t *testing.T) {
|
||||
for name, rawStatus := range map[string]interface{}{
|
||||
"null": nil,
|
||||
"string": "processing",
|
||||
"fractional": 0.5,
|
||||
"unknown value": 2,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move_to_drive",
|
||||
"--task-id", "raw-task-signature",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
|
||||
// status, so all must require wiki:space:read. A single table keeps this
|
||||
// invariant explicit without duplicating near-identical test functions.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
|
||||
// Every Wiki scenario reads Wiki task status, so all must require
|
||||
// wiki:space:read. A single table keeps this invariant explicit without
|
||||
// duplicating near-identical test functions.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node"} {
|
||||
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
|
||||
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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WikiMove,
|
||||
WikiMoveToDrive,
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
|
||||
415
shortcuts/wiki/wiki_move_to_drive.go
Normal file
415
shortcuts/wiki/wiki_move_to_drive.go
Normal file
@@ -0,0 +1,415 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// These are fixed backend wire values. Keep them unchanged even though the
|
||||
// shortcut and its continuation scenario use the user-facing Drive name.
|
||||
wikiMoveToDriveTaskType = "move_wiki_to_docs"
|
||||
wikiMoveToDriveResult = "move_wiki_to_docs_result"
|
||||
|
||||
wikiMoveToDriveStatusSuccess = 0
|
||||
wikiMoveToDriveStatusProcessing = 1
|
||||
wikiMoveToDriveStatusFailure = -1
|
||||
)
|
||||
|
||||
var (
|
||||
wikiMoveToDrivePollAttempts = 30
|
||||
wikiMoveToDrivePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// WikiMoveToDrive moves a Wiki node out of its knowledge space and into a
|
||||
// Drive folder. The API always creates an async task, so the shortcut polls the
|
||||
// Wiki task endpoint and returns a resumable command when the bounded window
|
||||
// expires.
|
||||
var WikiMoveToDrive = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+move-to-drive",
|
||||
Description: "Move a wiki node to a Drive folder, polling the async task until it finishes",
|
||||
Risk: "write",
|
||||
// The move endpoint's wiki:wiki / wiki:node:move /
|
||||
// space:document:move list is an OR-set, while Shortcut.Scopes is an
|
||||
// ALL-required preflight. Use the registry's highest-priority candidate
|
||||
// plus the read scope required by the task-status endpoint.
|
||||
Scopes: []string{"space:document:move", "wiki:space:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node_token to move out of the knowledge space", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token; omit to move to the calling identity's personal-space root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"The source must be a wiki node_token, not the backing document's obj_token; use wiki +node-get when unsure.",
|
||||
"Omit --folder-token to move the document to the calling identity's personal-space root.",
|
||||
"Moving out of Wiki removes the node from the Wiki tree and replaces inherited Wiki permissions with the target Drive folder's permission model.",
|
||||
"The move is asynchronous; if the bounded poll times out, continue with the next_command returned in the output.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateWikiMoveToDriveSpec(readWikiMoveToDriveSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return buildWikiMoveToDriveDryRun(readWikiMoveToDriveSpec(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiMoveToDriveSpec(runtime)
|
||||
out, err := runWikiMoveToDrive(ctx, wikiMoveToDriveAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type wikiMoveToDriveSpec struct {
|
||||
NodeToken string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func (spec wikiMoveToDriveSpec) RequestBody() map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if spec.FolderToken != "" {
|
||||
body["folder_token"] = spec.FolderToken
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
type wikiMoveToDriveTaskStatus struct {
|
||||
TaskID string
|
||||
Status int
|
||||
StatusMsg string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (s wikiMoveToDriveTaskStatus) Ready() bool {
|
||||
return s.Status == wikiMoveToDriveStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiMoveToDriveTaskStatus) Failed() bool {
|
||||
return s.Status < wikiMoveToDriveStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiMoveToDriveTaskStatus) StatusLabel() string {
|
||||
if label := strings.TrimSpace(s.StatusMsg); label != "" {
|
||||
return label
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
type wikiMoveToDriveClient interface {
|
||||
MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error)
|
||||
GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiMoveToDriveAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiMoveToDriveAPI) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki move-to-drive response missing task_id")
|
||||
}
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
func (api wikiMoveToDriveAPI) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveToDriveTaskStatus{}, err
|
||||
}
|
||||
return parseWikiMoveToDriveTaskStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func readWikiMoveToDriveSpec(runtime *common.RuntimeContext) wikiMoveToDriveSpec {
|
||||
return wikiMoveToDriveSpec{
|
||||
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
func validateWikiMoveToDriveSpec(spec wikiMoveToDriveSpec) error {
|
||||
if spec.NodeToken == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateOptionalResourceName(spec.FolderToken, "--folder-token")
|
||||
}
|
||||
|
||||
func buildWikiMoveToDriveDryRun(spec wikiMoveToDriveSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc(
|
||||
"2-step orchestration: move wiki node to Drive -> poll wiki move-to-drive task result",
|
||||
)
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[1] Move wiki node to Drive").
|
||||
Body(spec.RequestBody())
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[2] Poll wiki move-to-drive task result").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
|
||||
return dry
|
||||
}
|
||||
|
||||
func runWikiMoveToDrive(
|
||||
ctx context.Context,
|
||||
client wikiMoveToDriveClient,
|
||||
runtime *common.RuntimeContext,
|
||||
spec wikiMoveToDriveSpec,
|
||||
) (map[string]interface{}, error) {
|
||||
folderLabel := "personal-space root"
|
||||
if spec.FolderToken != "" {
|
||||
folderLabel = common.MaskToken(spec.FolderToken)
|
||||
}
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Moving wiki node %s to Drive folder %s...\n",
|
||||
common.MaskToken(spec.NodeToken),
|
||||
folderLabel,
|
||||
)
|
||||
|
||||
taskID, err := client.MoveWikiToDrive(ctx, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive is async, polling task %s...\n", taskID)
|
||||
status, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"node_token": spec.NodeToken,
|
||||
"folder_token": spec.FolderToken,
|
||||
"task_id": taskID,
|
||||
"ready": ready,
|
||||
"failed": status.Failed(),
|
||||
"status": status.Status,
|
||||
"status_msg": status.StatusLabel(),
|
||||
"obj_token": status.ObjToken,
|
||||
"obj_type": status.ObjType,
|
||||
"url": status.URL,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func pollWikiMoveToDriveTask(
|
||||
ctx context.Context,
|
||||
client wikiMoveToDriveClient,
|
||||
runtime *common.RuntimeContext,
|
||||
taskID string,
|
||||
) (wikiMoveToDriveTaskStatus, bool, error) {
|
||||
lastStatus := wikiMoveToDriveTaskStatus{
|
||||
TaskID: taskID,
|
||||
Status: wikiMoveToDriveStatusProcessing,
|
||||
}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
for attempt := 1; attempt <= wikiMoveToDrivePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
|
||||
ctx.Err(), taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
|
||||
)
|
||||
case <-time.After(wikiMoveToDrivePollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := client.GetMoveWikiToDriveTask(ctx, taskID)
|
||||
if err != nil {
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
|
||||
contextErr, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
|
||||
)
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive status attempt %d/%d failed: %v\n", attempt, wikiMoveToDrivePollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Wiki move-to-drive task completed successfully.")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, errs.NewAPIError(
|
||||
errs.SubtypeServerError,
|
||||
"wiki move-to-drive task %s failed: %s",
|
||||
taskID,
|
||||
status.StatusLabel(),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Wiki move-to-drive status %d/%d: %s\n",
|
||||
attempt,
|
||||
wikiMoveToDrivePollAttempts,
|
||||
status.StatusLabel(),
|
||||
)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
|
||||
err, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
|
||||
)
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki move-to-drive task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
taskID,
|
||||
wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime)),
|
||||
)
|
||||
if _, ok := errs.ProblemOf(lastErr); ok {
|
||||
return lastStatus, false, appendWikiProblemHint(lastErr, hint)
|
||||
}
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).
|
||||
WithHint("%s", hint).
|
||||
WithCause(lastErr)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveToDriveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveToDriveTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, wikiMoveToDriveResult)
|
||||
if result == nil {
|
||||
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"wiki task response missing %s",
|
||||
wikiMoveToDriveResult,
|
||||
)
|
||||
}
|
||||
statusCode, ok := common.GetFloatOK(result, "status")
|
||||
if !ok {
|
||||
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"wiki task response has missing or non-numeric %s.status",
|
||||
wikiMoveToDriveResult,
|
||||
)
|
||||
}
|
||||
if statusCode != wikiMoveToDriveStatusFailure &&
|
||||
statusCode != wikiMoveToDriveStatusSuccess &&
|
||||
statusCode != wikiMoveToDriveStatusProcessing {
|
||||
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"wiki task response has unsupported %s.status: %v",
|
||||
wikiMoveToDriveResult,
|
||||
statusCode,
|
||||
)
|
||||
}
|
||||
|
||||
status := wikiMoveToDriveTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
Status: int(statusCode),
|
||||
StatusMsg: common.GetString(result, "status_msg"),
|
||||
ObjToken: common.GetString(result, "obj_token"),
|
||||
ObjType: common.GetString(result, "obj_type"),
|
||||
URL: common.GetString(result, "url"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Preserve the originating identity and profile so a resumed status lookup
|
||||
// uses the same credential context that created the async task.
|
||||
func wikiMoveToDriveTaskResultCommand(taskID string, identity core.Identity, profileName string) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
profileFlag := ""
|
||||
if profileName != "" {
|
||||
profileFlag = fmt.Sprintf(" --profile %s", profileName)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"lark-cli%s drive +task_result --scenario wiki_move_to_drive --task-id %s --as %s",
|
||||
profileFlag,
|
||||
taskID,
|
||||
asFlag,
|
||||
)
|
||||
}
|
||||
|
||||
func wikiMoveToDriveProfileName(runtime *common.RuntimeContext) string {
|
||||
if runtime == nil || runtime.Config == nil {
|
||||
return ""
|
||||
}
|
||||
return runtime.Config.ProfileName
|
||||
}
|
||||
|
||||
func wrapWikiMoveToDrivePollContextError(err error, taskID string, identity core.Identity, profileName string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
message := "wiki move-to-drive task polling cancelled: %s"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
message = "wiki move-to-drive task polling deadline exceeded: %s"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, message, err).
|
||||
WithHint("the task may still be running; retry status lookup with: %s", wikiMoveToDriveTaskResultCommand(taskID, identity, profileName)).
|
||||
WithCause(err)
|
||||
}
|
||||
481
shortcuts/wiki/wiki_move_to_drive_test.go
Normal file
481
shortcuts/wiki/wiki_move_to_drive_test.go
Normal file
@@ -0,0 +1,481 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeWikiMoveToDriveClient struct {
|
||||
moveTaskID string
|
||||
moveErr error
|
||||
taskStatus []wikiMoveToDriveTaskStatus
|
||||
taskErrs []error
|
||||
taskHooks []func()
|
||||
|
||||
moveSpecs []wikiMoveToDriveSpec
|
||||
taskCalls []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveToDriveClient) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
|
||||
fake.moveSpecs = append(fake.moveSpecs, spec)
|
||||
if fake.moveErr != nil {
|
||||
return "", fake.moveErr
|
||||
}
|
||||
return fake.moveTaskID, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveToDriveClient) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
|
||||
idx := len(fake.taskCalls)
|
||||
fake.taskCalls = append(fake.taskCalls, taskID)
|
||||
if idx < len(fake.taskHooks) && fake.taskHooks[idx] != nil {
|
||||
fake.taskHooks[idx]()
|
||||
}
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatus) {
|
||||
status := fake.taskStatus[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, nil
|
||||
}
|
||||
|
||||
var wikiMoveToDrivePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiMoveToDrivePoll(t *testing.T) {
|
||||
withWikiMoveToDrivePoll(t, 1)
|
||||
}
|
||||
|
||||
func withWikiMoveToDrivePoll(t *testing.T, attempts int) {
|
||||
t.Helper()
|
||||
wikiMoveToDrivePollMu.Lock()
|
||||
|
||||
previousAttempts, previousInterval := wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval
|
||||
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = attempts, 0
|
||||
t.Cleanup(func() {
|
||||
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = previousAttempts, previousInterval
|
||||
wikiMoveToDrivePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiMoveToDriveRuntime(t *testing.T, identity core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(nil, cfg, identity)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func TestWikiMoveToDriveDeclaredContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantScopes := []string{"space:document:move", "wiki:space:read"}
|
||||
if !reflect.DeepEqual(WikiMoveToDrive.Scopes, wantScopes) {
|
||||
t.Fatalf("WikiMoveToDrive.Scopes = %v, want %v", WikiMoveToDrive.Scopes, wantScopes)
|
||||
}
|
||||
if WikiMoveToDrive.Risk != "write" {
|
||||
t.Fatalf("WikiMoveToDrive.Risk = %q, want write", WikiMoveToDrive.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiMoveToDriveSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("requires node token", func(t *testing.T) {
|
||||
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{})
|
||||
requireWikiValidationParams(t, err, "--node-token")
|
||||
})
|
||||
|
||||
t.Run("rejects unsafe folder token", func(t *testing.T) {
|
||||
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
FolderToken: "../folder",
|
||||
})
|
||||
requireWikiValidationParams(t, err, "--folder-token")
|
||||
cause := errors.Unwrap(err)
|
||||
if cause == nil || !errors.Is(err, cause) {
|
||||
t.Fatal("validation error must preserve its path-validation cause")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts optional folder", func(t *testing.T) {
|
||||
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
|
||||
if err != nil {
|
||||
t.Fatalf("validateWikiMoveToDriveSpec() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWikiMoveToDriveRequestBodyOmitsEmptyFolder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
withoutFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC"}).RequestBody()
|
||||
if _, exists := withoutFolder["folder_token"]; exists {
|
||||
t.Fatalf("empty folder_token must be omitted, got %#v", withoutFolder)
|
||||
}
|
||||
|
||||
withFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC", FolderToken: "fldABC"}).RequestBody()
|
||||
if withFolder["folder_token"] != "fldABC" {
|
||||
t.Fatalf("RequestBody() = %#v, want folder_token=fldABC", withFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiMoveToDriveDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
steps := decodeDryRunAPIs(t, buildWikiMoveToDriveDryRun(wikiMoveToDriveSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
FolderToken: "fldABC",
|
||||
}))
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("len(api) = %d, want 2", len(steps))
|
||||
}
|
||||
if steps[0].Method != "POST" || steps[0].URL != "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs" {
|
||||
t.Fatalf("POST step = %#v", steps[0])
|
||||
}
|
||||
if steps[0].Body["folder_token"] != "fldABC" {
|
||||
t.Fatalf("POST body = %#v", steps[0].Body)
|
||||
}
|
||||
if steps[1].Method != "GET" || steps[1].URL != "/open-apis/wiki/v2/tasks/%3Ctask_id%3E" {
|
||||
t.Fatalf("GET step = %#v", steps[1])
|
||||
}
|
||||
if steps[1].Params["task_type"] != wikiMoveToDriveTaskType {
|
||||
t.Fatalf("task params = %#v", steps[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveToDriveTaskStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success with task id fallback and result fields", func(t *testing.T) {
|
||||
status, err := parseWikiMoveToDriveTaskStatus("signed-task-id", map[string]interface{}{
|
||||
"move_wiki_to_docs_result": map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"status_msg": "success",
|
||||
"obj_token": "docxABC",
|
||||
"obj_type": "docx",
|
||||
"url": "https://example.feishu.cn/docx/docxABC",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveToDriveTaskStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "signed-task-id" || !status.Ready() || status.Failed() {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
if status.ObjToken != "docxABC" || status.ObjType != "docx" || status.URL == "" {
|
||||
t.Fatalf("result fields = %+v", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing dedicated result", func(t *testing.T) {
|
||||
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing status", func(t *testing.T) {
|
||||
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
|
||||
"move_wiki_to_docs_result": map[string]interface{}{},
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want invalid_response", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for name, rawStatus := range map[string]interface{}{
|
||||
"null": nil,
|
||||
"string": "processing",
|
||||
"fractional": 0.5,
|
||||
"unknown value": 2,
|
||||
} {
|
||||
t.Run("rejects "+name+" status", func(t *testing.T) {
|
||||
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
|
||||
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiMoveToDriveSuccess(t *testing.T) {
|
||||
withSingleWikiMoveToDrivePoll(t)
|
||||
runtime, stderr := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
moveTaskID: "raw-task-signature",
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{{
|
||||
Status: wikiMoveToDriveStatusSuccess,
|
||||
StatusMsg: "success",
|
||||
ObjToken: "docxABC",
|
||||
ObjType: "docx",
|
||||
URL: "https://example.feishu.cn/docx/docxABC",
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
FolderToken: "fldABC",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMoveToDrive() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "raw-task-signature" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("output = %#v", out)
|
||||
}
|
||||
if out["obj_token"] != "docxABC" || out["obj_type"] != "docx" || out["url"] == "" {
|
||||
t.Fatalf("output result fields = %#v", out)
|
||||
}
|
||||
if len(client.moveSpecs) != 1 || client.moveSpecs[0].FolderToken != "fldABC" {
|
||||
t.Fatalf("move specs = %#v", client.moveSpecs)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiMoveToDriveTimeoutReturnsResumeCommand(t *testing.T) {
|
||||
withSingleWikiMoveToDrivePoll(t)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsBot)
|
||||
runtime.Config.ProfileName = "secondary"
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
moveTaskID: "raw-task-signature",
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
|
||||
}
|
||||
|
||||
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMoveToDrive() error = %v", err)
|
||||
}
|
||||
if out["ready"] != false || out["failed"] != false || out["timed_out"] != true {
|
||||
t.Fatalf("timeout output = %#v", out)
|
||||
}
|
||||
nextCommand, _ := out["next_command"].(string)
|
||||
if !strings.HasPrefix(nextCommand, "lark-cli --profile secondary drive +task_result") ||
|
||||
!strings.Contains(nextCommand, "--scenario wiki_move_to_drive") ||
|
||||
!strings.Contains(nextCommand, "--as bot") {
|
||||
t.Fatalf("next_command = %q", nextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveToDriveContinuesFromProcessingToSuccess(t *testing.T) {
|
||||
withWikiMoveToDrivePoll(t, 2)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{
|
||||
{Status: wikiMoveToDriveStatusProcessing},
|
||||
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
|
||||
},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
|
||||
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
|
||||
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
|
||||
}
|
||||
if len(client.taskCalls) != 2 {
|
||||
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveToDriveRecoversFromTransientError(t *testing.T) {
|
||||
withWikiMoveToDrivePoll(t, 2)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
requestTimeout := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "temporary request timeout").
|
||||
WithCause(context.DeadlineExceeded)
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
taskErrs: []error{requestTimeout},
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{
|
||||
{},
|
||||
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
|
||||
},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
|
||||
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
|
||||
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
|
||||
}
|
||||
if len(client.taskCalls) != 2 {
|
||||
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveToDriveDoesNotSwallowFinalCancellation(t *testing.T) {
|
||||
withWikiMoveToDrivePoll(t, 2)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
|
||||
taskErrs: []error{nil, context.Canceled},
|
||||
taskHooks: []func(){nil, cancel},
|
||||
}
|
||||
|
||||
_, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, "signed-task-id")
|
||||
if ready || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ready=%t err=%T %v, want preserved context cancellation", ready, err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("error = %T %v, want network/transport", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiMoveToDriveFailureIsTypedAPIError(t *testing.T) {
|
||||
withSingleWikiMoveToDrivePoll(t)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
client := &fakeWikiMoveToDriveClient{
|
||||
moveTaskID: "raw-task-signature",
|
||||
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusFailure, StatusMsg: "failure"}},
|
||||
}
|
||||
|
||||
_, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("error = %T %v, want api/server_error", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveToDrivePreservesTypedPollError(t *testing.T) {
|
||||
withSingleWikiMoveToDrivePoll(t)
|
||||
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
|
||||
cause := errors.New("connection reset")
|
||||
upstream := errs.NewNetworkError(errs.SubtypeNetworkTransport, "poll failed").
|
||||
WithCode(503).
|
||||
WithHint("retry upstream").
|
||||
WithCause(cause)
|
||||
client := &fakeWikiMoveToDriveClient{taskErrs: []error{upstream}}
|
||||
|
||||
_, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "raw-task-signature")
|
||||
if ready || err != upstream {
|
||||
t.Fatalf("ready=%t err=%T %v, want original typed error", ready, err, err)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatal("typed poll error must preserve its cause")
|
||||
}
|
||||
problem, _ := errs.ProblemOf(err)
|
||||
if problem.Code != 503 || !strings.Contains(problem.Hint, "retry upstream") || !strings.Contains(problem.Hint, "wiki_move_to_drive") {
|
||||
t.Fatalf("problem = %+v", problem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiMoveToDrivePollContextError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := wrapWikiMoveToDrivePollContextError(context.DeadlineExceeded, "task-id", core.AsUser, "secondary")
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal("wrapped deadline must preserve context.DeadlineExceeded")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Fatalf("error = %T %v, want network/timeout", err, err)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "wiki_move_to_drive") || !strings.Contains(problem.Hint, "--profile secondary") {
|
||||
t.Fatalf("hint = %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveToDriveExecuteCallsPostAndTaskEndpoint(t *testing.T) {
|
||||
withSingleWikiMoveToDrivePoll(t)
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "raw-task-signature"},
|
||||
},
|
||||
}
|
||||
registry.Register(moveStub)
|
||||
|
||||
var taskQuery string
|
||||
taskStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// The external handler currently omits task.task_id for this
|
||||
// task type, so the CLI must preserve the signed request ID.
|
||||
"move_wiki_to_docs_result": map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"obj_token": "docxABC",
|
||||
"obj_type": "docx",
|
||||
"url": "https://example.feishu.cn/docx/docxABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
taskStub.OnMatch = func(req *http.Request) { taskQuery = req.URL.RawQuery }
|
||||
registry.Register(taskStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
|
||||
"+move-to-drive",
|
||||
"--node-token", "wikcnABC",
|
||||
"--folder-token", "fldABC",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
body := decodeWikiCapturedJSONBody(t, moveStub)
|
||||
if body["folder_token"] != "fldABC" {
|
||||
t.Fatalf("captured POST body = %#v", body)
|
||||
}
|
||||
if !strings.Contains(taskQuery, "task_type=move_wiki_to_docs") {
|
||||
t.Fatalf("task query = %q", taskQuery)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["task_id"] != "raw-task-signature" || data["ready"] != true || data["obj_token"] != "docxABC" {
|
||||
t.Fatalf("output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveToDriveExecuteRejectsMissingTaskID(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
|
||||
"+move-to-drive",
|
||||
"--node-token", "wikcnABC",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
@@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
feishu := core.Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
lark := core.Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。执行前确认源节点与目标位置。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
## 快速决策
|
||||
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md),该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move` 或 `drive +move`。执行前确认源节点与目标位置。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL:**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
|
||||
- URL(`.../wiki/<token>`):`lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`。
|
||||
@@ -37,4 +38,4 @@
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,再按源对象分流:源对象是 Wiki 节点时用 `wiki +move-to-drive`,源对象已在 Drive 时用 `drive +move`
|
||||
|
||||
@@ -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` 表示已经撤销,无需重复撤回。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user