mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
1 Commits
v1.0.13
...
fix/format
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ee0defab |
@@ -6,6 +6,3 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
116
.github/workflows/arch-audit.yml
vendored
@@ -1,116 +0,0 @@
|
||||
name: Architecture Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Monday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Dead code detection
|
||||
run: |
|
||||
echo "## Dead Code" >> report.md
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
|
||||
count=$(wc -l < deadcode.txt | tr -d ' ')
|
||||
echo "Found **$count** unreachable functions" >> report.md
|
||||
echo '```' >> report.md
|
||||
cat deadcode.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Package complexity
|
||||
run: |
|
||||
echo "## Package Complexity" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "| Package | Files | Lines | Deps |" >> report.md
|
||||
echo "|---------|-------|-------|------|" >> report.md
|
||||
found=0
|
||||
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
|
||||
dir=$(go list -f '{{.Dir}}' "$pkg")
|
||||
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
|
||||
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
|
||||
deps=$(go list -f '{{len .Imports}}' "$pkg")
|
||||
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
|
||||
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "| _(none)_ | | | |" >> report.md
|
||||
fi
|
||||
|
||||
- name: Dependency freshness
|
||||
run: |
|
||||
echo "## Outdated Dependencies" >> report.md
|
||||
echo '```' >> report.md
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Shortcut domains without E2E tests:" >> report.md
|
||||
echo "" >> report.md
|
||||
found=0
|
||||
for domain in $(ls -d shortcuts/*/); do
|
||||
name=$(basename "$domain")
|
||||
if [ "$name" = "common" ]; then continue; fi
|
||||
if [ ! -d "tests/cli_e2e/$name" ]; then
|
||||
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "All shortcut domains have E2E test directories." >> report.md
|
||||
fi
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
echo "## Coverage" >> report.md
|
||||
packages=$(go list ./... | grep -v 'tests/cli_e2e')
|
||||
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
|
||||
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
|
||||
echo "Current total coverage: **${total:-n/a}**" >> report.md
|
||||
|
||||
- name: Publish report
|
||||
run: |
|
||||
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cat report.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: arch-audit-${{ github.run_number }}
|
||||
path: report.md
|
||||
retention-days: 90
|
||||
333
.github/workflows/ci.yml
vendored
333
.github/workflows/ci.yml
vendored
@@ -1,333 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
fast-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "$unformatted"
|
||||
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
|
||||
exit 1
|
||||
fi
|
||||
- name: Check go.mod tidiness
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
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/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
|
||||
exit 1
|
||||
fi
|
||||
- name: Coverage summary
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [ ! -f coverage.txt ]; then
|
||||
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Dead code check (incremental)
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
git worktree remove -f /tmp/dc-base 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
git worktree remove -f /tmp/dc-base
|
||||
|
||||
# Only new dead code blocks the PR
|
||||
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
|
||||
if [ -s /tmp/dc-new.txt ]; then
|
||||
echo "::group::New dead code"
|
||||
cat /tmp/dc-new.txt
|
||||
echo "::endgroup::"
|
||||
echo "::error::New dead code detected — remove unreachable functions before merging"
|
||||
exit 1
|
||||
fi
|
||||
echo "No new dead code introduced"
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
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"
|
||||
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
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Gitleaks
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
- name: govulncheck
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
license-header:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
run: |
|
||||
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
|
||||
# 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.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo ""
|
||||
echo "::error::One or more CI jobs failed — see table above"
|
||||
exit 1
|
||||
fi
|
||||
83
.github/workflows/cli-e2e.yml
vendored
Normal file
83
.github/workflows/cli-e2e.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
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"
|
||||
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
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
58
.github/workflows/coverage.yml
vendored
Normal file
58
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
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/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
28
.github/workflows/gitleaks.yml
vendored
Normal file
28
.github/workflows/gitleaks.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
26
.github/workflows/license-header.yml
vendored
Normal file
26
.github/workflows/license-header.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
60
.github/workflows/lint.yml
vendored
Normal file
60
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
43
.github/workflows/tests.yml
vendored
Normal file
43
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
@@ -70,14 +70,6 @@ linters:
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
@@ -108,16 +100,6 @@ linters:
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
# ── output: shortcuts must use ctx.Out() ──
|
||||
- pattern: fmt\.Print(f|ln)?\b
|
||||
msg: >-
|
||||
use ctx.Out() or ctx.OutFormat() for structured JSON output.
|
||||
fmt.Print* bypasses the output envelope and breaks --jq/--format.
|
||||
# ── logging: shortcuts must return errors, not log.Fatal ──
|
||||
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
|
||||
msg: >-
|
||||
use structured error return, not log.Fatal/Panic.
|
||||
Shortcuts must return errors to the framework for proper exit code handling.
|
||||
# ── filepath: functions that access the filesystem ──
|
||||
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
|
||||
msg: >-
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -18,11 +18,9 @@ make test # Full: vet + unit + integration
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go vet ./...`
|
||||
3. `gofmt -l .` — must produce no output
|
||||
4. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
@@ -78,26 +76,3 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
### E2E Testing
|
||||
|
||||
**Dry-run E2E (required for every shortcut change)**
|
||||
- Validates request structure without calling real APIs
|
||||
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
|
||||
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
|
||||
- No secrets needed — runs on fork PRs
|
||||
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
|
||||
|
||||
**Live E2E (required for new flows or behavior changes)**
|
||||
- Validates real API round-trips
|
||||
- Place in `tests/cli_e2e/<domain>/`
|
||||
- Must be self-contained: create -> use -> cleanup
|
||||
- Needs bot credentials (CI secrets, skipped on fork PRs)
|
||||
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
|
||||
|
||||
| Change | Dry-run E2E | Live E2E |
|
||||
|--------|:-----------:|:--------:|
|
||||
| New shortcut | Required | Required |
|
||||
| Modify shortcut flags/params | Required | If behavior changes |
|
||||
| Shortcut bug fix | Required | If regression risk |
|
||||
| Internal refactor (no shortcut impact) | Not needed | Not needed |
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,60 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
|
||||
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
|
||||
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
|
||||
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
|
||||
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
|
||||
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
|
||||
|
||||
### CI
|
||||
|
||||
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
|
||||
|
||||
## [v1.0.12] - 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
|
||||
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
|
||||
- **mail**: Return recall hints for sent emails when recall is available (#481)
|
||||
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
|
||||
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
@@ -382,9 +328,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
@@ -29,11 +29,11 @@ type initMsg struct {
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
|
||||
@@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
|
||||
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
t.Helper()
|
||||
fields := map[string]string{
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
|
||||
@@ -101,16 +101,16 @@ type ServiceMethodOptions struct {
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -17,13 +18,39 @@ var knownArrayFields = []string{
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
}
|
||||
|
||||
// isSliceLike reports whether v is any kind of slice (e.g. []interface{},
|
||||
// []map[string]interface{}, []string, etc.), using reflect so that the
|
||||
// check is not limited to a single concrete slice type.
|
||||
func isSliceLike(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return reflect.TypeOf(v).Kind() == reflect.Slice
|
||||
}
|
||||
|
||||
// toGenericSlice converts any slice type to []interface{} by re-boxing each
|
||||
// element. This only changes the outer container type; individual elements
|
||||
// retain their original dynamic type (e.g. map[string]interface{} stays as-is).
|
||||
// Returns nil if v is not a slice.
|
||||
func toGenericSlice(v interface{}) []interface{} {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return nil
|
||||
}
|
||||
out := make([]interface{}, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
out[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
// It first checks knownArrayFields in priority order, then falls back to
|
||||
// the lexicographically smallest unknown array field for deterministic results.
|
||||
func FindArrayField(data map[string]interface{}) string {
|
||||
for _, name := range knownArrayFields {
|
||||
if arr, ok := data[name]; ok {
|
||||
if _, isArr := arr.([]interface{}); isArr {
|
||||
if isSliceLike(arr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -31,7 +58,7 @@ func FindArrayField(data map[string]interface{}) string {
|
||||
// Fallback: lexicographically first array field (deterministic)
|
||||
var candidates []string
|
||||
for k, v := range data {
|
||||
if _, isArr := v.([]interface{}); isArr {
|
||||
if isSliceLike(v) {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +108,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
||||
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
if field := FindArrayField(dataObj); field != "" {
|
||||
if items, ok := dataObj[field].([]interface{}); ok {
|
||||
if items := toGenericSlice(dataObj[field]); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -90,7 +117,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 2: direct map — result[arrayField]
|
||||
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
||||
if field := FindArrayField(resultMap); field != "" {
|
||||
if items, ok := resultMap[field].([]interface{}); ok {
|
||||
if items := toGenericSlice(resultMap[field]); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,129 @@ func TestExtractItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Typed-slice regression tests ---
|
||||
// These cover the scenario where shortcut code uses []map[string]interface{}
|
||||
// (or other typed slices) instead of []interface{} in outData.
|
||||
|
||||
func TestExtractItems_TypedMapSlice(t *testing.T) {
|
||||
// Simulates shortcut pattern: outData["chats"] = []map[string]interface{}{...}
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Test Chat"},
|
||||
{"chat_id": "oc_def", "name": "Dev Chat"},
|
||||
},
|
||||
"total": 2,
|
||||
"has_more": false,
|
||||
}
|
||||
items := ExtractItems(data)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items from typed map slice, got %d", len(items))
|
||||
}
|
||||
// Verify elements are still map[string]interface{} (flattenItem can handle them)
|
||||
for i, item := range items {
|
||||
if _, ok := item.(map[string]interface{}); !ok {
|
||||
t.Errorf("item[%d] should be map[string]interface{}, got %T", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractItems_TypedMapSlice_InEnvelope(t *testing.T) {
|
||||
// Typed slice inside a Lark API envelope: result["data"]["items"] = []map[string]interface{}{...}
|
||||
data := map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
}
|
||||
items := ExtractItems(data)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item from typed slice in envelope, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_Table_TypedMapSlice(t *testing.T) {
|
||||
// The core bug: --format table with []map[string]interface{} should render
|
||||
// multi-column table, not a key-value two-column fallback.
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Lark Dev"},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": false,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatTable)
|
||||
out := buf.String()
|
||||
|
||||
// Should have column headers from the data fields
|
||||
if !strings.Contains(out, "chat_id") {
|
||||
t.Errorf("table should contain 'chat_id' column header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "name") {
|
||||
t.Errorf("table should contain 'name' column header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Lark Dev") {
|
||||
t.Errorf("table should contain data value 'Lark Dev', got:\n%s", out)
|
||||
}
|
||||
// Should NOT render as key-value fallback (metadata as rows)
|
||||
if strings.Contains(out, "has_more") {
|
||||
t.Errorf("table should not contain metadata 'has_more' as a row, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_CSV_TypedMapSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{"message_id": "om_abc", "content": "hello"},
|
||||
{"message_id": "om_def", "content": "world"},
|
||||
},
|
||||
"total": 2,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatCSV)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("CSV should have header + 2 rows, got %d lines:\n%s", len(lines), buf.String())
|
||||
}
|
||||
// Header should contain data field names, not top-level map keys
|
||||
header := lines[0]
|
||||
if !strings.Contains(header, "message_id") {
|
||||
t.Errorf("CSV header should contain 'message_id', got: %s", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NDJSON_TypedMapSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{
|
||||
{"guid": "t1", "url": "https://example.com/t1"},
|
||||
{"guid": "t2", "url": "https://example.com/t2"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatNDJSON)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
for i, line := range lines {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Errorf("NDJSON line %d should be valid JSON: %s", i, line)
|
||||
}
|
||||
if _, ok := obj["guid"]; !ok {
|
||||
t.Errorf("NDJSON line %d should contain 'guid' field, got: %s", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_LegacyFormats(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -146,20 +146,11 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate installs skills, trying the .well-known source first and
|
||||
// falling back to the GitHub repo on failure or timeout.
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
}
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -168,7 +159,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate", "hello world", 5, "hello"},
|
||||
{"empty", "", 5, ""},
|
||||
{"zero limit", "hello", 0, ""},
|
||||
{"CJK characters", "你好世界测试", 4, "你好世界"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStr(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStrWithEllipsis(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate with ellipsis", "hello world", 8, "hello..."},
|
||||
{"limit less than 3", "hello", 2, "he"},
|
||||
{"limit equals 3", "hello world", 3, "..."},
|
||||
{"empty", "", 5, ""},
|
||||
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
package-lock.json
generated
84
package-lock.json
generated
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
|
||||
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.2.0",
|
||||
"fast-string-width": "^1.1.0",
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
|
||||
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
|
||||
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
|
||||
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.10",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -27,11 +27,7 @@
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"scripts/install.js",
|
||||
"scripts/install-wizard.js",
|
||||
"scripts/run.js",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const messages = {
|
||||
zh: {
|
||||
setup: "正在设置 Feishu/Lark CLI...",
|
||||
step1: "正在安装 %s...",
|
||||
step1Upgrade: "正在升级 %s (v%s → v%s)...",
|
||||
step1Skip: "已安装 (v%s),跳过",
|
||||
step1Done: "已全局安装",
|
||||
step1Upgraded: "已升级到 v%s",
|
||||
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
|
||||
step2: "安装 AI Skills",
|
||||
step2Skip: "已安装,跳过",
|
||||
step2Spinner: "正在安装 Skills...",
|
||||
step2Done: "Skills 已安装",
|
||||
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
|
||||
step3: "正在配置应用...",
|
||||
step3NotFound: "未找到 lark-cli,终止",
|
||||
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
|
||||
step3Skip: "跳过应用配置",
|
||||
step3Done: "应用已配置",
|
||||
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
||||
step4: "授权",
|
||||
step4NotFound: "未找到 lark-cli,跳过授权",
|
||||
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
|
||||
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
||||
step4Done: "授权完成",
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n现在可以对你的 AI 工具(Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
},
|
||||
en: {
|
||||
setup: "Setting up Feishu/Lark CLI...",
|
||||
step1: "Installing %s globally...",
|
||||
step1Upgrade: "Upgrading %s (v%s → v%s)...",
|
||||
step1Skip: "Already installed (v%s). Skipped",
|
||||
step1Done: "Installed globally",
|
||||
step1Upgraded: "Upgraded to v%s",
|
||||
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
|
||||
step2: "Install AI skills",
|
||||
step2Skip: "Already installed. Skipped",
|
||||
step2Spinner: "Installing skills...",
|
||||
step2Done: "Skills installed",
|
||||
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
|
||||
step3: "Configuring app...",
|
||||
step3NotFound: "lark-cli not found. Aborting",
|
||||
step3Found: "Found existing app (App ID: %s). Use this app?",
|
||||
step3Skip: "Skipped app configuration",
|
||||
step3Done: "App configured",
|
||||
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
||||
step4: "Authorization",
|
||||
step4NotFound: "lark-cli not found. Skipping authorization",
|
||||
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
|
||||
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
||||
step4Done: "Authorization complete",
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
|
||||
cancelled: "Installation cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleCancel(value, msg) {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function execCmd(cmd, args, opts) {
|
||||
if (isWindows) {
|
||||
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
|
||||
}
|
||||
return execFileSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
execCmd(cmd, args, { stdio: "inherit", ...opts });
|
||||
}
|
||||
|
||||
function runSilent(cmd, args, opts = {}) {
|
||||
return execCmd(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function runSilentAsync(cmd, args, opts = {}) {
|
||||
const actualCmd = isWindows ? "cmd.exe" : cmd;
|
||||
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(actualCmd, actualArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
}, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(template, ...values) {
|
||||
let i = 0;
|
||||
return template.replace(/%s/g, () => values[i++] ?? "");
|
||||
}
|
||||
|
||||
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
|
||||
function whichLarkCli() {
|
||||
try {
|
||||
const prefix = execFileSync("npm", ["prefix", "-g"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).toString().trim();
|
||||
const bin = isWindows
|
||||
? path.join(prefix, "lark-cli.cmd")
|
||||
: path.join(prefix, "bin", "lark-cli");
|
||||
if (fs.existsSync(bin)) return bin;
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
// Fallback to which/where if npm prefix lookup fails.
|
||||
try {
|
||||
const cmd = isWindows ? "where" : "which";
|
||||
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
|
||||
.toString()
|
||||
.split("\n")[0]
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
|
||||
function getLatestVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
|
||||
const ver = out.toString().trim();
|
||||
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare two semver strings. Returns true if a < b. */
|
||||
function semverLessThan(a, b) {
|
||||
const pa = a.replace(/-.*$/, "").split(".").map(Number);
|
||||
const pb = b.replace(/-.*$/, "").split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
|
||||
function getGloballyInstalledVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
|
||||
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
|
||||
return match ? match[1] : "unknown";
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether lark-cli config already exists. Returns app ID or null. */
|
||||
function getExistingAppId(binPath) {
|
||||
try {
|
||||
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
|
||||
const json = JSON.parse(out.toString());
|
||||
return json.appId || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse --lang from process.argv, returns "zh", "en", or null. */
|
||||
function parseLangArg() {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--lang" && args[i + 1]) {
|
||||
const val = args[i + 1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
if (args[i].startsWith("--lang=")) {
|
||||
const val = args[i].split("=")[1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stepSelectLang() {
|
||||
const fromArg = parseLangArg();
|
||||
if (fromArg) return fromArg;
|
||||
|
||||
const lang = await p.select({
|
||||
message: "请选择语言 / Select language",
|
||||
options: [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
],
|
||||
});
|
||||
return handleCancel(lang, messages.zh);
|
||||
}
|
||||
|
||||
async function stepInstallGlobally(msg) {
|
||||
const installedVer = getGloballyInstalledVersion();
|
||||
const latestVer = getLatestVersion();
|
||||
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
|
||||
|
||||
if (installedVer && !needsUpgrade) {
|
||||
p.log.info(fmt(msg.step1Skip, installedVer));
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
if (needsUpgrade) {
|
||||
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
|
||||
} else {
|
||||
s.start(fmt(msg.step1, PKG));
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
||||
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
||||
return needsUpgrade;
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step1Fail, PKG));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function skillsAlreadyInstalled() {
|
||||
try {
|
||||
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
return /^lark-/m.test(out.toString());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
s.stop(msg.step2Skip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
s.stop(msg.step2Done);
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepConfigInit(msg, lang) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step3);
|
||||
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
s.stop(msg.step3NotFound);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const appId = getExistingAppId(larkCli);
|
||||
s.stop(msg.step3);
|
||||
|
||||
if (appId) {
|
||||
const reuse = await p.confirm({
|
||||
message: fmt(msg.step3Found, appId),
|
||||
});
|
||||
if (handleCancel(reuse, msg) && reuse) {
|
||||
p.log.info(msg.step3Skip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
run(larkCli, ["config", "init", "--new", "--lang", lang]);
|
||||
p.log.success(msg.step3Done);
|
||||
} catch (_) {
|
||||
p.log.error(msg.step3Fail);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepAuthLogin(msg) {
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
p.log.warn(msg.step4NotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
const yes = await p.confirm({
|
||||
message: msg.step4Confirm,
|
||||
});
|
||||
if (p.isCancel(yes)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!yes) {
|
||||
p.log.info(msg.step4Skip);
|
||||
return;
|
||||
}
|
||||
|
||||
p.log.step(msg.step4);
|
||||
try {
|
||||
run(larkCli, ["auth", "login"]);
|
||||
p.log.success(msg.step4Done);
|
||||
} catch (_) {
|
||||
p.log.warn(msg.step4Fail);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const lang = await stepSelectLang();
|
||||
const msg = messages[lang];
|
||||
|
||||
p.intro(msg.setup);
|
||||
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
|
||||
p.outro(msg.done);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const VERSION = require("../package.json").version;
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
|
||||
@@ -43,16 +43,13 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
const args = [
|
||||
"--fail", "--location", "--silent", "--show-error",
|
||||
"--connect-timeout", "10", "--max-time", "120",
|
||||
"--output", destPath,
|
||||
];
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
||||
args.push(url);
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
}
|
||||
|
||||
function install() {
|
||||
@@ -67,12 +64,12 @@ function install() {
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
} else {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
@@ -88,16 +85,6 @@ function install() {
|
||||
}
|
||||
}
|
||||
|
||||
// When triggered as a postinstall hook under npx, skip the binary download.
|
||||
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
||||
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
||||
const isNpxPostinstall =
|
||||
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
||||
|
||||
if (isNpxPostinstall) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,32 +41,21 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept "install" subcommand — run the setup wizard directly,
|
||||
// bypassing the native binary (which may not exist yet under npx).
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "install") {
|
||||
require("./install-wizard.js");
|
||||
} else {
|
||||
// Auto-download binary if missing (e.g. npx skipped postinstall).
|
||||
if (!fs.existsSync(bin)) {
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, LARK_CLI_RUN: "true" },
|
||||
});
|
||||
} catch (_) {
|
||||
console.error(
|
||||
`\nFailed to auto-install lark-cli binary.\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(bin, args, { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
if (!fs.existsSync(bin)) {
|
||||
console.error(
|
||||
`Error: lark-cli binary not found at ${bin}\n\n` +
|
||||
`This usually means the postinstall script was skipped.\n` +
|
||||
`Common causes:\n` +
|
||||
` - npm is configured with ignore-scripts=true\n` +
|
||||
` - The postinstall download failed\n\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
|
||||
@@ -151,87 +151,6 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err should not mention array: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Fatalf("stdout=%q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -340,7 +259,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
||||
@@ -358,7 +277,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
||||
@@ -1284,7 +1203,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate == nil {
|
||||
t.Fatal("expected validate hook")
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -255,29 +255,22 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("create validate err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,16 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
return parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = common.ParseJSON([]byte(raw), &body)
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
||||
|
||||
@@ -6,7 +6,6 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -37,14 +36,7 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -38,10 +38,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
|
||||
@@ -66,7 +63,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -284,11 +281,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
|
||||
@@ -25,9 +25,6 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
|
||||
@@ -113,9 +113,7 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
|
||||
@@ -25,9 +25,6 @@ var BaseRecordSearch = common.Shortcut{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
|
||||
@@ -26,9 +26,6 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
|
||||
}
|
||||
|
||||
func validateViewCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseObjectList(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONObject(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONValue(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeViewList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
return validateViewJSONValue(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetGroup,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
return validateViewJSONValue(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetSort,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,9 +26,6 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
|
||||
@@ -263,8 +263,8 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
// regardless of the current --as flag. Use this for APIs that must always be called
|
||||
// with TAT even when the surrounding shortcut runs as user.
|
||||
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
|
||||
// that must be called with TAT even when the surrounding shortcut runs as user.
|
||||
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -28,8 +28,6 @@ var DocsFetch = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
@@ -48,8 +46,6 @@ var DocsFetch = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveCreateFolderSpec struct {
|
||||
Name string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateFolderSpec(runtime *common.RuntimeContext) driveCreateFolderSpec {
|
||||
return driveCreateFolderSpec{
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
func (s driveCreateFolderSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"folder_token": s.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateFolder creates a new Drive folder under the specified parent
|
||||
// folder, or under the caller's root folder when --folder-token is omitted.
|
||||
var DriveCreateFolder = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-folder",
|
||||
Description: "Create a folder in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:folder:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "folder name", Required: true},
|
||||
{Name: "folder-token", Desc: "parent folder token (default: root folder)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit --folder-token to create the folder in the caller's root folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateFolderSpec(runtime)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Create a folder in Drive").
|
||||
POST("/open-apis/drive/v1/files/create_folder").
|
||||
Desc("[1] Create folder").
|
||||
Body(spec.RequestBody())
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateFolderSpec(runtime)
|
||||
|
||||
target := "root folder"
|
||||
if spec.FolderToken != "" {
|
||||
target = "folder " + common.MaskToken(spec.FolderToken)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folderToken := common.GetString(data, "token")
|
||||
if folderToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"name": spec.Name,
|
||||
"folder_token": folderToken,
|
||||
"parent_folder_token": spec.FolderToken,
|
||||
}
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
|
||||
if spec.Name == "" {
|
||||
return output.ErrValidation("--name must not be empty")
|
||||
}
|
||||
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
|
||||
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateDriveCreateFolderSpecRejectsInvalidInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateFolderSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
spec: driveCreateFolderSpec{},
|
||||
wantErr: "--name must not be empty",
|
||||
},
|
||||
{
|
||||
name: "name too long",
|
||||
spec: driveCreateFolderSpec{
|
||||
Name: strings.Repeat("a", 257),
|
||||
},
|
||||
wantErr: "maximum of 256 bytes",
|
||||
},
|
||||
{
|
||||
name: "invalid folder token",
|
||||
spec: driveCreateFolderSpec{
|
||||
Name: "Reports",
|
||||
FolderToken: "../bad",
|
||||
},
|
||||
wantErr: "--folder-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateFolderSpec(tt.spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-folder"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("name", " Weekly Reports "); err != nil {
|
||||
t.Fatalf("set --name: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " fld_parent "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveCreateFolder.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 {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/drive/v1/files/create_folder" {
|
||||
t.Fatalf("unexpected dry-run API call: %#v", got.API[0])
|
||||
}
|
||||
if got.API[0].Body["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", got.API[0].Body["name"], "Weekly Reports")
|
||||
}
|
||||
if got.API[0].Body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_created",
|
||||
"url": "https://example.feishu.cn/drive/folder/fld_created",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/fld_created/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", " Weekly Reports ",
|
||||
"--folder-token", " fld_parent ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", body["name"], "Weekly Reports")
|
||||
}
|
||||
if body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", body["folder_token"], "fld_parent")
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["folder_token"] != "fld_created" {
|
||||
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_created")
|
||||
}
|
||||
if data["parent_folder_token"] != "fld_parent" {
|
||||
t.Fatalf("parent_folder_token = %#v, want %q", data["parent_folder_token"], "fld_parent")
|
||||
}
|
||||
if data["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", data["name"], "Weekly Reports")
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/drive/folder/fld_created" {
|
||||
t.Fatalf("url = %#v, want folder url", data["url"])
|
||||
}
|
||||
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
permBody := decodeCapturedJSONBody(t, permStub)
|
||||
if permBody["member_type"] != "openid" || permBody["member_id"] != "ou_current_user" || permBody["perm"] != "full_access" || permBody["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", permBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_root_child",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Inbox",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["folder_token"] != "" {
|
||||
t.Fatalf("folder_token = %#v, want empty string for root create", body["folder_token"])
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["folder_token"] != "fld_root_child" {
|
||||
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_root_child")
|
||||
}
|
||||
if data["parent_folder_token"] != "" {
|
||||
t.Fatalf("parent_folder_token = %#v, want empty string", data["parent_folder_token"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://example.feishu.cn/drive/folder/unknown",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Broken Folder",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "returned no folder token") {
|
||||
t.Fatalf("err = %v, want missing folder token error", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on error, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateFolder,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
|
||||
@@ -12,7 +12,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-folder",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
|
||||
@@ -74,7 +74,6 @@ var commonEventTypes = []string{
|
||||
"approval.approval.updated",
|
||||
"application.application.visibility.added_v6",
|
||||
"task.task.update_tenant_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"task.task.comment_updated_v1",
|
||||
"drive.notice.comment_add_v1",
|
||||
}
|
||||
|
||||
@@ -602,51 +602,3 @@ func TestMediaBufferReader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaBufferFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
label string
|
||||
buf mediaBuffer
|
||||
want string
|
||||
}{
|
||||
{"original URL filename", mediaBuffer{name: "report.pdf", ext: ".pdf"}, "report.pdf"},
|
||||
{"name with spaces", mediaBuffer{name: "Q1 report.pdf", ext: ".pdf"}, "Q1 report.pdf"},
|
||||
{"download fallback", mediaBuffer{name: "download", ext: ""}, "download"},
|
||||
{"ext not leaked into name", mediaBuffer{name: "x", ext: ".mp4"}, "x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.label, func(t *testing.T) {
|
||||
if got := tt.buf.FileName(); got != tt.want {
|
||||
t.Fatalf("FileName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMediaBufferFromBytesURLFilename locks in the URL -> mediaBuffer.name
|
||||
// wiring so a future refactor cannot regress back to the "media.<ext>" synthetic
|
||||
// filename that was shipped in 91067ec.
|
||||
func TestNewMediaBufferFromBytesURLFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
label string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"path filename", "http://example.com/report.pdf", "report.pdf"},
|
||||
{"filename survives query string", "http://example.com/videos/clip.mp4?token=abc", "clip.mp4"},
|
||||
{"percent-encoded spaces decoded", "http://example.com/Q1%20report.pdf", "Q1 report.pdf"},
|
||||
{"no path falls back to download", "http://example.com/", "download"},
|
||||
{"non-http scheme falls back to download", "ftp://example.com/x.pdf", "download"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.label, func(t *testing.T) {
|
||||
mb := newMediaBufferFromBytes([]byte("payload"), ".pdf", tt.url)
|
||||
if got := mb.FileName(); got != tt.want {
|
||||
t.Fatalf("FileName() for %q = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
if got := mb.FileName(); strings.HasPrefix(got, "media") && tt.want != "media" {
|
||||
t.Fatalf("regression: FileName() returned synthetic %q for %q", got, tt.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,6 @@ func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType strin
|
||||
type mediaBuffer struct {
|
||||
data []byte
|
||||
ext string // file extension including leading dot, e.g. ".mp4"
|
||||
name string // original file name extracted from the source URL
|
||||
}
|
||||
|
||||
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
|
||||
@@ -599,14 +598,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
||||
}
|
||||
|
||||
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
|
||||
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
|
||||
// without going through the hardened download transport.
|
||||
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
|
||||
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
|
||||
return &mediaBuffer{data: data, ext: ext}, nil
|
||||
}
|
||||
|
||||
// Reader returns a new io.Reader over the buffered data. Each call returns a
|
||||
@@ -616,9 +608,9 @@ func (b *mediaBuffer) Reader() io.Reader {
|
||||
return bytes.NewReader(b.data)
|
||||
}
|
||||
|
||||
// FileName returns the original file name extracted from the source URL.
|
||||
// FileName returns a synthetic file name based on the URL extension.
|
||||
func (b *mediaBuffer) FileName() string {
|
||||
return b.name
|
||||
return "media" + b.ext
|
||||
}
|
||||
|
||||
// FileType returns the IM file type detected from the extension.
|
||||
@@ -1139,7 +1131,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -1180,7 +1172,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
@@ -1208,7 +1200,7 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", r)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -1240,7 +1232,7 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
}
|
||||
fd.AddFile("file", r)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
|
||||
@@ -892,38 +892,3 @@ func TestResolveLocalMediaFile(t *testing.T) {
|
||||
t.Fatalf("resolveLocalMedia(file) = %q, want %q", got, "file_via_resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadFileToIMPreservesLocalFileName locks in that local uploads keep
|
||||
// the basename of the caller-supplied path as the multipart file_name, so the
|
||||
// URL-side fix for mediaBuffer cannot silently regress the local branch later.
|
||||
func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
|
||||
var gotBody string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/open-apis/im/v1/files") {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotBody = string(body)
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_key": "file_uploaded"},
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
localName := "Q1-meeting-notes.pdf"
|
||||
if err := os.WriteFile(localName, []byte("pdfdata"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
||||
t.Fatalf("upload body missing local filename %q; got: %q", localName, gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,6 @@ type DraftProjection struct {
|
||||
BodyText string `json:"body_text,omitempty"`
|
||||
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
|
||||
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
|
||||
HasSignature bool `json:"has_signature,omitempty"`
|
||||
SignatureID string `json:"signature_id,omitempty"`
|
||||
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
|
||||
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
@@ -184,22 +182,6 @@ type PatchOp struct {
|
||||
FileName string `json:"filename,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Target AttachmentTarget `json:"target,omitempty"`
|
||||
SignatureID string `json:"signature_id,omitempty"`
|
||||
|
||||
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
|
||||
// fetching and interpolating the signature. The patch layer uses this
|
||||
// pre-rendered content for insert_signature ops.
|
||||
RenderedSignatureHTML string `json:"-"`
|
||||
SignatureImages []SignatureImage `json:"-"`
|
||||
}
|
||||
|
||||
// SignatureImage holds pre-downloaded image data for signature inline images.
|
||||
// Populated by the shortcut layer, consumed by the patch layer.
|
||||
type SignatureImage struct {
|
||||
CID string
|
||||
ContentType string
|
||||
FileName string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func (p Patch) Validate() error {
|
||||
@@ -292,12 +274,6 @@ func (op PatchOp) Validate() error {
|
||||
if !op.Target.hasKey() {
|
||||
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
|
||||
}
|
||||
case "insert_signature":
|
||||
if strings.TrimSpace(op.SignatureID) == "" {
|
||||
return fmt.Errorf("insert_signature requires signature_id")
|
||||
}
|
||||
case "remove_signature":
|
||||
// No required fields.
|
||||
default:
|
||||
return fmt.Errorf("unsupported op %q", op.Op)
|
||||
}
|
||||
|
||||
@@ -33,12 +33,10 @@ var protectedHeaders = map[string]bool{
|
||||
// bodyChangingOps lists patch operations that modify the HTML body content,
|
||||
// which is the trigger for running local image path resolution.
|
||||
var bodyChangingOps = map[string]bool{
|
||||
"set_body": true,
|
||||
"set_reply_body": true,
|
||||
"replace_body": true,
|
||||
"append_body": true,
|
||||
"insert_signature": true,
|
||||
"remove_signature": true,
|
||||
"set_body": true,
|
||||
"set_reply_body": true,
|
||||
"replace_body": true,
|
||||
"append_body": true,
|
||||
}
|
||||
|
||||
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
|
||||
@@ -123,10 +121,6 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
|
||||
return fmt.Errorf("remove_inline: %w", err)
|
||||
}
|
||||
return removeInline(snapshot, partID)
|
||||
case "insert_signature":
|
||||
return insertSignatureOp(snapshot, op)
|
||||
case "remove_signature":
|
||||
return removeSignatureOp(snapshot)
|
||||
default:
|
||||
return fmt.Errorf("unsupported patch op %q", op.Op)
|
||||
}
|
||||
@@ -290,7 +284,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e
|
||||
if htmlPart == nil {
|
||||
return setBody(snapshot, value, options)
|
||||
}
|
||||
_, quotePart := SplitAtQuote(string(htmlPart.Body))
|
||||
_, quotePart := splitAtQuote(string(htmlPart.Body))
|
||||
if quotePart == "" {
|
||||
// No quote block found — fall back to regular set_body.
|
||||
return setBody(snapshot, value, options)
|
||||
@@ -1141,166 +1135,3 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
|
||||
removeOrphanedInlineParts(snapshot.Body, refSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Signature patch operations ──
|
||||
|
||||
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
|
||||
// The RenderedSignatureHTML and SignatureImages fields must be populated
|
||||
// by the shortcut layer before calling Apply.
|
||||
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if htmlPart == nil {
|
||||
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
|
||||
}
|
||||
html := string(htmlPart.Body)
|
||||
|
||||
// Collect CIDs from old signature before removing it, so we can
|
||||
// clean up orphaned MIME inline parts and avoid duplicates.
|
||||
oldSigCIDs := collectSignatureCIDsFromHTML(html)
|
||||
|
||||
// Remove existing signature (if any), including preceding spacing.
|
||||
html = RemoveSignatureHTML(html)
|
||||
|
||||
// Remove orphaned MIME inline parts from old signature.
|
||||
for _, cid := range oldSigCIDs {
|
||||
if !containsCIDIgnoreCase(html, cid) {
|
||||
removeMIMEPartByCID(snapshot.Body, cid)
|
||||
}
|
||||
}
|
||||
|
||||
// Split at quote and insert signature between body and quote.
|
||||
body, quote := SplitAtQuote(html)
|
||||
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
|
||||
html = body + sigBlock + quote
|
||||
|
||||
htmlPart.Body = []byte(html)
|
||||
htmlPart.Dirty = true
|
||||
|
||||
// Add signature inline images to the MIME tree.
|
||||
for _, img := range op.SignatureImages {
|
||||
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
|
||||
}
|
||||
|
||||
syncTextPartFromHTML(snapshot, html)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSignatureOp removes the signature block from the HTML body.
|
||||
func removeSignatureOp(snapshot *DraftSnapshot) error {
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if htmlPart == nil {
|
||||
return fmt.Errorf("remove_signature: no HTML body part found")
|
||||
}
|
||||
html := string(htmlPart.Body)
|
||||
|
||||
if !signatureWrapperRe.MatchString(html) {
|
||||
return fmt.Errorf("no signature found in draft body")
|
||||
}
|
||||
|
||||
// Collect CIDs referenced by the signature before removing it.
|
||||
sigCIDs := collectSignatureCIDsFromHTML(html)
|
||||
|
||||
// Remove signature and preceding spacing.
|
||||
html = RemoveSignatureHTML(html)
|
||||
|
||||
// Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
|
||||
for _, cid := range sigCIDs {
|
||||
if !containsCIDIgnoreCase(html, cid) {
|
||||
removeMIMEPartByCID(snapshot.Body, cid)
|
||||
}
|
||||
}
|
||||
|
||||
htmlPart.Body = []byte(html)
|
||||
htmlPart.Dirty = true
|
||||
|
||||
syncTextPartFromHTML(snapshot, html)
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncTextPartFromHTML regenerates the text/plain part from the current HTML,
|
||||
// mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
|
||||
func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) {
|
||||
if snapshot.PrimaryTextPartID == "" {
|
||||
return
|
||||
}
|
||||
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
|
||||
if textPart == nil {
|
||||
return
|
||||
}
|
||||
textPart.Body = []byte(plainTextFromHTML(html))
|
||||
textPart.Dirty = true
|
||||
}
|
||||
|
||||
// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
|
||||
// RemoveSignatureHTML are exported from projection.go to avoid duplication
|
||||
// with the mail package's signature_html.go.
|
||||
|
||||
// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
|
||||
func collectSignatureCIDsFromHTML(html string) []string {
|
||||
loc := signatureWrapperRe.FindStringIndex(html)
|
||||
if loc == nil {
|
||||
return nil
|
||||
}
|
||||
sigEnd := FindMatchingCloseDiv(html, loc[0])
|
||||
sigHTML := html[loc[0]:sigEnd]
|
||||
|
||||
matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1)
|
||||
cids := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
if len(m) >= 2 {
|
||||
cids = append(cids, m[1])
|
||||
}
|
||||
}
|
||||
return cids
|
||||
}
|
||||
|
||||
// removeMIMEPartByCID removes the first MIME part with the given Content-ID.
|
||||
func removeMIMEPartByCID(root *Part, cid string) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
normalizedCID := strings.Trim(cid, "<>")
|
||||
for i, child := range root.Children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
childCID := strings.Trim(child.ContentID, "<>")
|
||||
if strings.EqualFold(childCID, normalizedCID) {
|
||||
root.Children = append(root.Children[:i], root.Children[i+1:]...)
|
||||
return
|
||||
}
|
||||
removeMIMEPartByCID(child, cid)
|
||||
}
|
||||
}
|
||||
|
||||
// addInlinePartToSnapshot adds an inline image part to the MIME tree.
|
||||
func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) {
|
||||
part := &Part{
|
||||
MediaType: contentType,
|
||||
ContentDisposition: "inline",
|
||||
ContentID: strings.Trim(cid, "<>"),
|
||||
Body: data,
|
||||
Dirty: true,
|
||||
}
|
||||
if filename != "" {
|
||||
part.MediaParams = map[string]string{"name": filename}
|
||||
}
|
||||
// Find or create the multipart/related container.
|
||||
if snapshot.Body == nil {
|
||||
return
|
||||
}
|
||||
if snapshot.Body.IsMultipart() {
|
||||
snapshot.Body.Children = append(snapshot.Body.Children, part)
|
||||
}
|
||||
// Non-multipart body: inline part is not added. This is expected when
|
||||
// the draft has a simple text/html body without multipart/related wrapper.
|
||||
// The signature HTML still references the CID, but the image won't render.
|
||||
// In practice, compose shortcuts wrap the body in multipart/related when
|
||||
// inline images are present, so this path rarely triggers.
|
||||
}
|
||||
|
||||
// containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
|
||||
// case-insensitively. Aligned with other CID comparisons in this package.
|
||||
func containsCIDIgnoreCase(html, cid string) bool {
|
||||
return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid))
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insert_signature — basic insertion into HTML body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInsertSignature_BasicHTML(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Sig test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "insert_signature",
|
||||
SignatureID: "sig-123",
|
||||
RenderedSignatureHTML: "<div>-- My Signature</div>",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply insert_signature: %v", err)
|
||||
}
|
||||
|
||||
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
|
||||
if !strings.Contains(html, "My Signature") {
|
||||
t.Error("signature not found in HTML body")
|
||||
}
|
||||
if !strings.Contains(html, `class="lark-mail-signature"`) {
|
||||
t.Error("signature wrapper class not found")
|
||||
}
|
||||
if !strings.Contains(html, `id="sig-123"`) {
|
||||
t.Error("signature ID not found")
|
||||
}
|
||||
// Body text should come before signature
|
||||
bodyIdx := strings.Index(html, "Hello")
|
||||
sigIdx := strings.Index(html, "My Signature")
|
||||
if bodyIdx > sigIdx {
|
||||
t.Error("signature should appear after body text")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insert_signature — with quoted content (reply/forward)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInsertSignature_BeforeQuote(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Reply with sig
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>My reply</p><div id="lark-mail-quote-cli123" class="history-quote-wrapper"><div>quoted content</div></div>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "insert_signature",
|
||||
SignatureID: "sig-456",
|
||||
RenderedSignatureHTML: "<div>-- Reply Sig</div>",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply insert_signature: %v", err)
|
||||
}
|
||||
|
||||
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
|
||||
sigIdx := strings.Index(html, "Reply Sig")
|
||||
quoteIdx := strings.Index(html, "quoted content")
|
||||
if sigIdx < 0 || quoteIdx < 0 {
|
||||
t.Fatalf("missing signature or quote in: %s", html)
|
||||
}
|
||||
if sigIdx > quoteIdx {
|
||||
t.Error("signature should appear before quote block")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insert_signature — replaces existing signature
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInsertSignature_ReplacesExisting(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Replace sig
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p><div id="old-sig" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- Old Sig</div></div>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "insert_signature",
|
||||
SignatureID: "new-sig",
|
||||
RenderedSignatureHTML: "<div>-- New Sig</div>",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply insert_signature: %v", err)
|
||||
}
|
||||
|
||||
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
|
||||
if strings.Contains(html, "Old Sig") {
|
||||
t.Error("old signature should have been removed")
|
||||
}
|
||||
if !strings.Contains(html, "New Sig") {
|
||||
t.Error("new signature not found")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insert_signature — no HTML body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInsertSignature_NoHTMLBody(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Plain text
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Just plain text`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "insert_signature",
|
||||
SignatureID: "sig-x",
|
||||
RenderedSignatureHTML: "<div>sig</div>",
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for insert_signature on plain text draft")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no HTML body") {
|
||||
t.Fatalf("expected 'no HTML body' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_signature — removes existing signature
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveSignature_Basic(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Remove sig
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p><div id="sig-rm" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- My Sig</div></div>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_signature"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply remove_signature: %v", err)
|
||||
}
|
||||
|
||||
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
|
||||
if strings.Contains(html, "My Sig") {
|
||||
t.Error("signature should have been removed")
|
||||
}
|
||||
if strings.Contains(html, "lark-mail-signature") {
|
||||
t.Error("signature wrapper should have been removed")
|
||||
}
|
||||
if !strings.Contains(html, "Hello") {
|
||||
t.Error("body text should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_signature — no signature present
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveSignature_NoSignature(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: No sig
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>No signature here</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_signature"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when removing non-existent signature")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no signature found") {
|
||||
t.Fatalf("expected 'no signature found' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package draft
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -28,18 +27,6 @@ var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapper
|
||||
|
||||
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
|
||||
|
||||
// SignatureWrapperClass is the CSS class for the mail signature container.
|
||||
const SignatureWrapperClass = "lark-mail-signature"
|
||||
|
||||
var signatureWrapperRe = regexp.MustCompile(
|
||||
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
|
||||
|
||||
// signatureIDRe extracts the id from a signature wrapper div, regardless of
|
||||
// whether id appears before or after the class attribute.
|
||||
var signatureIDRe = regexp.MustCompile(
|
||||
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` +
|
||||
`|<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
|
||||
|
||||
func Project(snapshot *DraftSnapshot) DraftProjection {
|
||||
proj := DraftProjection{
|
||||
Subject: snapshot.Subject,
|
||||
@@ -58,17 +45,6 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
|
||||
html := string(part.Body)
|
||||
proj.BodyHTMLSummary = summarizeHTML(html)
|
||||
proj.HasQuotedContent = hasQuotedContent(html)
|
||||
proj.HasSignature = signatureWrapperRe.MatchString(html)
|
||||
if proj.HasSignature {
|
||||
if m := signatureIDRe.FindStringSubmatch(html); m != nil {
|
||||
// alternation regex: id is in m[1] (class-first) or m[2] (id-first)
|
||||
if m[1] != "" {
|
||||
proj.SignatureID = m[1]
|
||||
} else if len(m) >= 3 {
|
||||
proj.SignatureID = m[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts := flattenParts(snapshot.Body)
|
||||
@@ -152,10 +128,10 @@ func hasQuotedContent(html string) bool {
|
||||
return quoteWrapperRe.MatchString(html)
|
||||
}
|
||||
|
||||
// SplitAtQuote splits an HTML body into the user-authored content and
|
||||
// splitAtQuote splits an HTML body into the user-authored content and
|
||||
// the trailing reply/forward quote block. If no quote block is found,
|
||||
// quote is empty and body is the original html unchanged.
|
||||
func SplitAtQuote(html string) (body, quote string) {
|
||||
func splitAtQuote(html string) (body, quote string) {
|
||||
loc := quoteWrapperRe.FindStringIndex(html)
|
||||
if loc == nil {
|
||||
return html, ""
|
||||
@@ -163,70 +139,6 @@ func SplitAtQuote(html string) (body, quote string) {
|
||||
return html[:loc[0]], html[loc[0]:]
|
||||
}
|
||||
|
||||
// ── Exported signature HTML utilities ──
|
||||
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
|
||||
|
||||
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
|
||||
var signatureSpacingRe = regexp.MustCompile(
|
||||
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
|
||||
|
||||
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
|
||||
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
|
||||
|
||||
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
|
||||
// matching the structure generated by the Lark mail editor.
|
||||
func SignatureSpacing() string {
|
||||
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
|
||||
return line + line
|
||||
}
|
||||
|
||||
// BuildSignatureHTML wraps signature content in the standard signature container div.
|
||||
// sigID is HTML-escaped to prevent attribute injection.
|
||||
func BuildSignatureHTML(sigID, content string) string {
|
||||
return `<div id="` + html.EscapeString(sigID) + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
|
||||
}
|
||||
|
||||
// FindMatchingCloseDiv finds the position after the closing </div> that matches
|
||||
// the <div at startPos, tracking nesting depth.
|
||||
func FindMatchingCloseDiv(html string, startPos int) int {
|
||||
depth := 0
|
||||
i := startPos
|
||||
for i < len(html) {
|
||||
if strings.HasPrefix(html[i:], "<div") {
|
||||
depth++
|
||||
i += 4
|
||||
} else if strings.HasPrefix(html[i:], "</div>") {
|
||||
depth--
|
||||
i += 6
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return len(html)
|
||||
}
|
||||
|
||||
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
|
||||
// Returns the HTML unchanged if no signature is found.
|
||||
func RemoveSignatureHTML(html string) string {
|
||||
loc := signatureWrapperRe.FindStringIndex(html)
|
||||
if loc == nil {
|
||||
return html
|
||||
}
|
||||
sigStart := loc[0]
|
||||
sigEnd := FindMatchingCloseDiv(html, sigStart)
|
||||
|
||||
// Extend backward to include preceding spacing.
|
||||
beforeSig := html[:sigStart]
|
||||
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
|
||||
sigStart = spacingLoc[0]
|
||||
}
|
||||
|
||||
return html[:sigStart] + html[sigEnd:]
|
||||
}
|
||||
|
||||
func summarizeHTML(html string) string {
|
||||
trimmed := strings.TrimSpace(html)
|
||||
runes := []rune(trimmed)
|
||||
|
||||
@@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
|
||||
func TestSplitAtQuoteReply(t *testing.T) {
|
||||
html := `<div>My reply</div><div class="history-quote-wrapper"><div>quoted</div></div>`
|
||||
body, quote := SplitAtQuote(html)
|
||||
body, quote := splitAtQuote(html)
|
||||
if body != `<div>My reply</div>` {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) {
|
||||
|
||||
func TestSplitAtQuoteForward(t *testing.T) {
|
||||
html := `<div>note</div><div id="lark-mail-quote-cli123456" class="history-quote-wrapper"><div>quoted</div></div>`
|
||||
body, quote := SplitAtQuote(html)
|
||||
body, quote := splitAtQuote(html)
|
||||
if body != `<div>note</div>` {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) {
|
||||
|
||||
func TestSplitAtQuoteNoQuote(t *testing.T) {
|
||||
html := `<div>no quote here</div>`
|
||||
body, quote := SplitAtQuote(html)
|
||||
body, quote := splitAtQuote(html)
|
||||
if body != html {
|
||||
t.Fatalf("body = %q, want original html", body)
|
||||
}
|
||||
@@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
|
||||
func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
|
||||
html := `<p>The CSS class history-quote-wrapper is used for quotes.</p>`
|
||||
body, quote := SplitAtQuote(html)
|
||||
body, quote := splitAtQuote(html)
|
||||
if body != html {
|
||||
t.Fatalf("body should be unchanged, got %q", body)
|
||||
}
|
||||
|
||||
@@ -1933,23 +1933,6 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSendResult builds the output map for a successful send, including
|
||||
// recall tip if the backend indicates the message is recallable.
|
||||
func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"message_id": resData["message_id"],
|
||||
"thread_id": resData["thread_id"],
|
||||
}
|
||||
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
|
||||
messageID, _ := resData["message_id"].(string)
|
||||
result["recall_available"] = true
|
||||
result["recall_tip"] = fmt.Sprintf(
|
||||
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
|
||||
mailboxID, messageID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateFolderReadScope checks that the user's token includes the
|
||||
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
|
||||
// before hitting the folders API. System folders are resolved locally and
|
||||
|
||||
@@ -46,7 +46,6 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
signatureFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
@@ -73,9 +72,6 @@ var MailDraftCreate = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("body")) == "" {
|
||||
return output.ErrValidation("--body is required; pass the full email body")
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -86,15 +82,11 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create draft failed: %w", err)
|
||||
@@ -129,7 +121,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) {
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
@@ -161,18 +153,12 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
var autoResolvedPaths []string
|
||||
if input.PlainText {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
} else if bodyIsHTML(input.Body) || sigResult != nil {
|
||||
htmlBody := input.Body
|
||||
if !bodyIsHTML(input.Body) {
|
||||
htmlBody = buildBodyDiv(input.Body, false)
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
|
||||
} else if bodyIsHTML(input.Body) {
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
|
||||
if resolveErr != nil {
|
||||
return "", resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
bld = bld.HTMLBody([]byte(resolved))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
var allCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
@@ -183,7 +169,6 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
allCIDs = append(allCIDs, spec.CID)
|
||||
}
|
||||
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
|
||||
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -92,24 +92,6 @@ var MailDraftEdit = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("parse draft raw EML failed: %v", err)
|
||||
}
|
||||
// Pre-process insert_signature ops: resolve signature using the draft's
|
||||
// From address so alias/shared-mailbox senders get correct template vars.
|
||||
var draftFromEmail string
|
||||
if len(snapshot.From) > 0 {
|
||||
draftFromEmail = snapshot.From[0].Address
|
||||
}
|
||||
for i := range patch.Ops {
|
||||
if patch.Ops[i].Op == "insert_signature" {
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
if sigResult != nil {
|
||||
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
|
||||
patch.Ops[i].SignatureImages = sigResult.Images
|
||||
}
|
||||
}
|
||||
}
|
||||
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
|
||||
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
|
||||
return output.ErrValidation("apply draft patch failed: %v", err)
|
||||
@@ -331,8 +313,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
|
||||
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"},
|
||||
},
|
||||
"supported_ops_by_group": []map[string]interface{}{
|
||||
{
|
||||
@@ -368,13 +348,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"group": "signature",
|
||||
"ops": []map[string]interface{}{
|
||||
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
|
||||
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommended_usage": []string{
|
||||
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",
|
||||
|
||||
@@ -34,7 +34,7 @@ var MailForward = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -64,9 +64,6 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -80,12 +77,7 @@ var MailForward = common.Shortcut{
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
@@ -122,7 +114,7 @@ var MailForward = common.Shortcut{
|
||||
if messageId != "" {
|
||||
bld = bld.LMSReplyToMessageID(messageId)
|
||||
}
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
@@ -146,13 +138,8 @@ var MailForward = common.Shortcut{
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
fullHTML := bodyWithSig + forwardQuote
|
||||
fullHTML := resolved + forwardQuote
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
@@ -163,7 +150,7 @@ var MailForward = common.Shortcut{
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -235,7 +222,10 @@ var MailForward = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
runtime.Out(buildSendResult(resData, mailboxID), nil)
|
||||
runtime.Out(map[string]interface{}{
|
||||
"message_id": resData["message_id"],
|
||||
"thread_id": resData["thread_id"],
|
||||
}, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ var MailReply = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -56,9 +56,6 @@ var MailReply = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -77,12 +74,7 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
@@ -100,7 +92,7 @@ var MailReply = common.Shortcut{
|
||||
}
|
||||
replyTo = mergeAddrLists(replyTo, toFlag)
|
||||
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
@@ -147,13 +139,8 @@ var MailReply = common.Shortcut{
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
fullHTML := bodyWithSig + quoted
|
||||
fullHTML := resolved + quoted
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
@@ -164,7 +151,7 @@ var MailReply = common.Shortcut{
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -198,7 +185,10 @@ var MailReply = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
runtime.Out(buildSendResult(resData, mailboxID), nil)
|
||||
runtime.Out(map[string]interface{}{
|
||||
"message_id": resData["message_id"],
|
||||
"thread_id": resData["thread_id"],
|
||||
}, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -57,9 +57,6 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -79,12 +76,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||||
@@ -118,7 +110,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
|
||||
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
@@ -161,13 +153,8 @@ var MailReplyAll = common.Shortcut{
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
bodyWithSig := resolved
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
fullHTML := bodyWithSig + quoted
|
||||
fullHTML := resolved + quoted
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
@@ -178,7 +165,7 @@ var MailReplyAll = common.Shortcut{
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -212,7 +199,10 @@ var MailReplyAll = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
runtime.Out(buildSendResult(resData, mailboxID), nil)
|
||||
runtime.Out(map[string]interface{}{
|
||||
"message_id": resData["message_id"],
|
||||
"thread_id": resData["thread_id"],
|
||||
}, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ var MailSend = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -62,9 +62,6 @@ var MailSend = common.Shortcut{
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -79,13 +76,6 @@ var MailSend = common.Shortcut{
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
signatureID := runtime.Str("signature-id")
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
|
||||
Subject(subject).
|
||||
@@ -106,19 +96,12 @@ var MailSend = common.Shortcut{
|
||||
var autoResolvedPaths []string
|
||||
if plainText {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
} else if bodyIsHTML(body) || sigResult != nil {
|
||||
// If signature is requested on plain-text body, auto-upgrade to HTML.
|
||||
htmlBody := body
|
||||
if !bodyIsHTML(body) {
|
||||
htmlBody = buildBodyDiv(body, false)
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
|
||||
} else if bodyIsHTML(body) {
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
bld = bld.HTMLBody([]byte(resolved))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
var allCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
@@ -129,7 +112,6 @@ var MailSend = common.Shortcut{
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
allCIDs = append(allCIDs, spec.CID)
|
||||
}
|
||||
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
|
||||
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,6 +132,7 @@ var MailSend = common.Shortcut{
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
}
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
@@ -166,7 +149,10 @@ var MailSend = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
runtime.Out(buildSendResult(resData, mailboxID), nil)
|
||||
runtime.Out(map[string]interface{}{
|
||||
"message_id": resData["message_id"],
|
||||
"thread_id": resData["thread_id"],
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/signature"
|
||||
)
|
||||
|
||||
var MailSignature = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+signature",
|
||||
Description: "List or view email signatures with default usage info.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Default: "me", Desc: "Mailbox address (default: me)"},
|
||||
{Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := runtime.Str("from")
|
||||
if mailboxID == "" {
|
||||
mailboxID = "me"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List or view email signatures").
|
||||
GET(mailboxPath(mailboxID, "signatures"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
mailboxID := runtime.Str("from")
|
||||
if mailboxID == "" {
|
||||
mailboxID = "me"
|
||||
}
|
||||
detailID := runtime.Str("detail")
|
||||
|
||||
resp, err := signature.ListAll(runtime, mailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if detailID != "" {
|
||||
return executeSignatureDetail(runtime, resp, detailID, mailboxID)
|
||||
}
|
||||
return executeSignatureList(runtime, resp)
|
||||
},
|
||||
}
|
||||
|
||||
func executeSignatureList(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse) error {
|
||||
// Build default signature ID maps from usages.
|
||||
sendDefaults := map[string]bool{}
|
||||
replyDefaults := map[string]bool{}
|
||||
for _, usage := range resp.Usages {
|
||||
if usage.SendMailSignatureID != "" && usage.SendMailSignatureID != "0" {
|
||||
sendDefaults[usage.SendMailSignatureID] = true
|
||||
}
|
||||
if usage.ReplySignatureID != "" && usage.ReplySignatureID != "0" {
|
||||
replyDefaults[usage.ReplySignatureID] = true
|
||||
}
|
||||
}
|
||||
|
||||
lang := resolveLang(runtime)
|
||||
items := make([]map[string]interface{}, 0, len(resp.Signatures))
|
||||
for _, sig := range resp.Signatures {
|
||||
item := map[string]interface{}{
|
||||
"id": sig.ID,
|
||||
"name": sig.Name,
|
||||
"type": string(sig.SignatureType),
|
||||
}
|
||||
if len(sig.Images) > 0 {
|
||||
item["images"] = len(sig.Images)
|
||||
}
|
||||
|
||||
// Short content preview (rendered for TENANT).
|
||||
rendered := signature.InterpolateTemplate(&sig, lang, "", "")
|
||||
item["content_preview"] = contentPreview(rendered, 200, lang)
|
||||
|
||||
if sendDefaults[sig.ID] {
|
||||
item["is_send_default"] = true
|
||||
}
|
||||
if replyDefaults[sig.ID] {
|
||||
item["is_reply_default"] = true
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
runtime.OutFormat(
|
||||
map[string]interface{}{"signatures": items},
|
||||
&output.Meta{Count: len(items)},
|
||||
nil,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse, sigID, mailboxID string) error {
|
||||
var sig *signature.Signature
|
||||
for i := range resp.Signatures {
|
||||
if resp.Signatures[i].ID == sigID {
|
||||
sig = &resp.Signatures[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if sig == nil {
|
||||
return output.ErrValidation("signature not found: %s", sigID)
|
||||
}
|
||||
|
||||
lang := resolveLang(runtime)
|
||||
|
||||
detail := map[string]interface{}{
|
||||
"id": sig.ID,
|
||||
"name": sig.Name,
|
||||
"type": string(sig.SignatureType),
|
||||
}
|
||||
|
||||
// Usage info.
|
||||
for _, usage := range resp.Usages {
|
||||
if usage.SendMailSignatureID == sig.ID {
|
||||
detail["is_send_default"] = true
|
||||
}
|
||||
if usage.ReplySignatureID == sig.ID {
|
||||
detail["is_reply_default"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Images metadata — output the full structure from API.
|
||||
if len(sig.Images) > 0 {
|
||||
detail["images"] = sig.Images
|
||||
}
|
||||
|
||||
// Template variables (TENANT signatures): show resolved values.
|
||||
if sig.HasTemplateVars() {
|
||||
vars := make(map[string]string, len(sig.UserFields))
|
||||
for key, field := range sig.UserFields {
|
||||
vars[key] = field.Resolve(lang)
|
||||
}
|
||||
detail["template_vars"] = vars
|
||||
}
|
||||
|
||||
// Rendered content preview.
|
||||
rendered := signature.InterpolateTemplate(sig, lang, "", "")
|
||||
detail["content_preview"] = contentPreview(rendered, 200, lang)
|
||||
|
||||
runtime.Out(detail, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
|
||||
func resolveLang(runtime *common.RuntimeContext) string {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return "zh_cn"
|
||||
}
|
||||
cfg, err := runtime.Factory.Config()
|
||||
if err != nil {
|
||||
return "zh_cn"
|
||||
}
|
||||
app := multi.FindApp(cfg.ProfileName)
|
||||
if app == nil {
|
||||
return "zh_cn"
|
||||
}
|
||||
switch app.Lang {
|
||||
case "en":
|
||||
return "en_us"
|
||||
case "ja":
|
||||
return "ja_jp"
|
||||
default:
|
||||
return "zh_cn"
|
||||
}
|
||||
}
|
||||
|
||||
// contentPreview converts HTML to a compact plain-text preview.
|
||||
// <img> tags become a localized image placeholder, all other tags become
|
||||
// spaces, then consecutive whitespace is collapsed. Result is truncated
|
||||
// to maxLen runes.
|
||||
func contentPreview(html string, maxLen int, lang string) string {
|
||||
placeholder := "[image]"
|
||||
if strings.HasPrefix(lang, "zh") {
|
||||
placeholder = "[图片]"
|
||||
}
|
||||
imgRe := regexp.MustCompile(`<img[^>]*>`)
|
||||
s := imgRe.ReplaceAllString(html, placeholder)
|
||||
|
||||
// Strip remaining tags, replacing each with a space.
|
||||
var result strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
result.WriteByte(' ')
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse whitespace and trim.
|
||||
text := strings.Join(strings.Fields(result.String()), " ")
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
@@ -19,6 +19,5 @@ func Shortcuts() []common.Shortcut {
|
||||
MailDraftCreate,
|
||||
MailDraftEdit,
|
||||
MailForward,
|
||||
MailSignature,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// variableMetaProps represents the JSON structure in data-variable-meta-props attributes.
|
||||
type variableMetaProps struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "text" or "image"
|
||||
DisplayName string `json:"displayName"` // human-readable label
|
||||
Width string `json:"width"` // image width (for type=image)
|
||||
Style string `json:"style"` // CSS style
|
||||
Circle bool `json:"circle"` // circular image
|
||||
}
|
||||
|
||||
// variableSpanRe matches <span data-variable-meta-props='...'> and captures the JSON and inner content.
|
||||
// Group 1: JSON attribute value (double-quoted), Group 2: (single-quoted), Group 3: inner content.
|
||||
//
|
||||
// Limitation: uses regex instead of DOM parsing (Go has no built-in DOMParser like JS).
|
||||
// If a variable <span> contains nested <span> tags, [\s\S]*? will match to the
|
||||
// innermost </span>, potentially truncating content. In practice, Lark's signature
|
||||
// templates do not nest <span> inside variable spans (verified against mail-editor
|
||||
// source and test data). If this becomes an issue, consider using golang.org/x/net/html.
|
||||
var variableSpanRe = regexp.MustCompile(
|
||||
`<span\s+data-variable-meta-props=(?:"([^"]*?)"|'([^']*?)')>([\s\S]*?)</span>`)
|
||||
|
||||
// InterpolateTemplate replaces template variables in a TENANT signature's content HTML.
|
||||
// For USER signatures (no template variables), it returns sig.Content unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - sig: the signature object
|
||||
// - lang: language code for i18n ("zh_cn", "en_us", "ja_jp")
|
||||
// - senderName: sender display name (overrides B-NAME)
|
||||
// - senderEmail: sender email address (overrides B-ENTERPRISE-EMAIL)
|
||||
func InterpolateTemplate(sig *Signature, lang, senderName, senderEmail string) string {
|
||||
if !sig.HasTemplateVars() {
|
||||
return sig.Content
|
||||
}
|
||||
|
||||
// Build value map from user_fields with i18n resolution.
|
||||
valueMap := make(map[string]string, len(sig.UserFields)+2)
|
||||
for key, field := range sig.UserFields {
|
||||
valueMap[key] = field.Resolve(lang)
|
||||
}
|
||||
|
||||
// Fixed injections override API values.
|
||||
if senderName != "" {
|
||||
valueMap["B-NAME"] = senderName
|
||||
}
|
||||
if senderEmail != "" {
|
||||
valueMap["B-ENTERPRISE-EMAIL"] = senderEmail
|
||||
}
|
||||
|
||||
// Replace each <span data-variable-meta-props='...'> with interpolated content.
|
||||
result := variableSpanRe.ReplaceAllStringFunc(sig.Content, func(match string) string {
|
||||
submatches := variableSpanRe.FindStringSubmatch(match)
|
||||
if submatches == nil {
|
||||
return match
|
||||
}
|
||||
|
||||
// JSON is in group 1 (double-quoted) or group 2 (single-quoted).
|
||||
attrJSON := submatches[1]
|
||||
if attrJSON == "" {
|
||||
attrJSON = submatches[2]
|
||||
}
|
||||
|
||||
// Unescape HTML entities in the JSON attribute value.
|
||||
attrJSON = unescapeHTMLEntities(attrJSON)
|
||||
|
||||
var meta variableMetaProps
|
||||
if err := json.Unmarshal([]byte(attrJSON), &meta); err != nil {
|
||||
return match // preserve original on parse failure
|
||||
}
|
||||
|
||||
val, ok := valueMap[meta.ID]
|
||||
if !ok {
|
||||
val = "" // variable not in map, replace with empty
|
||||
}
|
||||
|
||||
switch meta.Type {
|
||||
case "text":
|
||||
return interpolateText(val, meta.Style)
|
||||
case "image":
|
||||
return interpolateImage(val, meta)
|
||||
default:
|
||||
return val
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// interpolateText returns the replacement for a text variable.
|
||||
func interpolateText(val, style string) string {
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
// If value looks like a URL, wrap in <a>.
|
||||
if isURL(val) {
|
||||
escaped := escapeHTML(val)
|
||||
return `<a href="` + escaped + `" target="_blank" rel="noopener noreferrer">` + escaped + `</a>`
|
||||
}
|
||||
if style != "" {
|
||||
return `<span style="` + escapeHTML(style) + `">` + escapeHTML(val) + `</span>`
|
||||
}
|
||||
return escapeHTML(val)
|
||||
}
|
||||
|
||||
// interpolateImage returns the replacement for an image variable.
|
||||
func interpolateImage(val string, meta variableMetaProps) string {
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
var attrs []string
|
||||
attrs = append(attrs, `src="`+escapeHTML(val)+`"`)
|
||||
if meta.Width != "" {
|
||||
attrs = append(attrs, `width="`+escapeHTML(meta.Width)+`"`)
|
||||
}
|
||||
var styles []string
|
||||
if meta.Style != "" {
|
||||
styles = append(styles, meta.Style)
|
||||
}
|
||||
if meta.Circle {
|
||||
styles = append(styles, "border-radius: 100%")
|
||||
}
|
||||
if len(styles) > 0 {
|
||||
attrs = append(attrs, `style="`+escapeHTML(strings.Join(styles, ";"))+`"`)
|
||||
}
|
||||
return `<img ` + strings.Join(attrs, " ") + `>`
|
||||
}
|
||||
|
||||
func isURL(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
|
||||
func unescapeHTMLEntities(s string) string {
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInterpolateTemplate_UserSignatureUnchanged(t *testing.T) {
|
||||
sig := &Signature{
|
||||
Content: "<b>My signature</b>",
|
||||
SignatureType: SignatureTypeUser,
|
||||
}
|
||||
got := InterpolateTemplate(sig, "zh_cn", "Alice", "alice@example.com")
|
||||
if got != sig.Content {
|
||||
t.Errorf("USER signature should be unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolateTemplate_TenantTextVariables(t *testing.T) {
|
||||
sig := &Signature{
|
||||
Content: `姓名:<span data-variable-meta-props='{"id":"B-NAME","type":"text"}'>{text}</span>, 部门:<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
|
||||
SignatureType: SignatureTypeTenant,
|
||||
TemplateJSONKeys: []string{"B-NAME", "B-DEPARTMENT"},
|
||||
UserFields: map[string]UserFieldValue{
|
||||
"B-NAME": {DefaultVal: "张三", I18nVals: map[string]string{"zh_cn": "", "en_us": "Zhang San"}},
|
||||
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "研发部", "en_us": "R&D"}},
|
||||
},
|
||||
}
|
||||
|
||||
// zh_cn: B-DEPARTMENT should resolve to "研发部" (from i18n), B-NAME overridden by senderName
|
||||
got := InterpolateTemplate(sig, "zh_cn", "李四", "lisi@example.com")
|
||||
if !strings.Contains(got, "李四") {
|
||||
t.Errorf("expected senderName override for B-NAME, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "研发部") {
|
||||
t.Errorf("expected zh_cn i18n value for B-DEPARTMENT, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "{text}") {
|
||||
t.Errorf("should not contain raw placeholder {text}, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "data-variable-meta-props") {
|
||||
t.Errorf("should not contain data-variable-meta-props attribute, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolateTemplate_I18nFallback(t *testing.T) {
|
||||
sig := &Signature{
|
||||
Content: `<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
|
||||
SignatureType: SignatureTypeTenant,
|
||||
TemplateJSONKeys: []string{"B-DEPARTMENT"},
|
||||
UserFields: map[string]UserFieldValue{
|
||||
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "", "en_us": ""}},
|
||||
},
|
||||
}
|
||||
|
||||
got := InterpolateTemplate(sig, "zh_cn", "", "")
|
||||
if !strings.Contains(got, "默认部门") {
|
||||
t.Errorf("expected fallback to DefaultVal, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolateTemplate_HTMLEntityEscaping(t *testing.T) {
|
||||
// Simulate the HTML-entity-escaped attribute format from real API responses.
|
||||
sig := &Signature{
|
||||
Content: `<span data-variable-meta-props="{"id":"B-NAME","type":"text"}">{text}</span>`,
|
||||
SignatureType: SignatureTypeTenant,
|
||||
TemplateJSONKeys: []string{"B-NAME"},
|
||||
UserFields: map[string]UserFieldValue{
|
||||
"B-NAME": {DefaultVal: "default"},
|
||||
},
|
||||
}
|
||||
|
||||
got := InterpolateTemplate(sig, "zh_cn", "陈煌", "")
|
||||
if !strings.Contains(got, "陈煌") {
|
||||
t.Errorf("expected interpolated name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolateTemplate_URLAsText(t *testing.T) {
|
||||
sig := &Signature{
|
||||
Content: `<span data-variable-meta-props='{"id":"B-URL","type":"text"}'>{text}</span>`,
|
||||
SignatureType: SignatureTypeTenant,
|
||||
TemplateJSONKeys: []string{"B-URL"},
|
||||
UserFields: map[string]UserFieldValue{
|
||||
"B-URL": {DefaultVal: "https://example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
got := InterpolateTemplate(sig, "zh_cn", "", "")
|
||||
if !strings.Contains(got, "<a href=") {
|
||||
t.Errorf("expected URL to be wrapped in <a> tag, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "https://example.com") {
|
||||
t.Errorf("expected URL in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolateTemplate_ImageVariable(t *testing.T) {
|
||||
sig := &Signature{
|
||||
Content: `<span data-variable-meta-props='{"id":"B-LOGO","type":"image","width":"40"}'><img src="cid:old"/></span>`,
|
||||
SignatureType: SignatureTypeTenant,
|
||||
TemplateJSONKeys: []string{"B-LOGO"},
|
||||
UserFields: map[string]UserFieldValue{
|
||||
"B-LOGO": {DefaultVal: "cid:new-logo-cid"},
|
||||
},
|
||||
}
|
||||
|
||||
got := InterpolateTemplate(sig, "zh_cn", "", "")
|
||||
if !strings.Contains(got, `src="cid:new-logo-cid"`) {
|
||||
t.Errorf("expected new image src, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `width="40"`) {
|
||||
t.Errorf("expected width attribute, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFieldValue_Resolve(t *testing.T) {
|
||||
v := UserFieldValue{
|
||||
DefaultVal: "default",
|
||||
I18nVals: map[string]string{"zh_cn": "中文", "en_us": "", "ja_jp": "日本語"},
|
||||
}
|
||||
if got := v.Resolve("zh_cn"); got != "中文" {
|
||||
t.Errorf("zh_cn = %q, want 中文", got)
|
||||
}
|
||||
if got := v.Resolve("en_us"); got != "default" {
|
||||
t.Errorf("en_us (empty) should fallback to default, got %q", got)
|
||||
}
|
||||
if got := v.Resolve("ja_jp"); got != "日本語" {
|
||||
t.Errorf("ja_jp = %q, want 日本語", got)
|
||||
}
|
||||
if got := v.Resolve("fr_fr"); got != "default" {
|
||||
t.Errorf("unknown lang should fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
// SignatureType represents the type of a mail signature.
|
||||
type SignatureType string
|
||||
|
||||
const (
|
||||
SignatureTypeUser SignatureType = "USER"
|
||||
SignatureTypeTenant SignatureType = "TENANT"
|
||||
)
|
||||
|
||||
// SignatureDevice represents the device platform a signature is designed for.
|
||||
type SignatureDevice string
|
||||
|
||||
const (
|
||||
DevicePC SignatureDevice = "PC"
|
||||
DeviceMobile SignatureDevice = "MOBILE"
|
||||
)
|
||||
|
||||
// SignatureImage holds metadata for an inline image embedded in a signature.
|
||||
type SignatureImage struct {
|
||||
ImageName string `json:"image_name,omitempty"`
|
||||
FileKey string `json:"file_key,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Header string `json:"header,omitempty"`
|
||||
ImageWidth int32 `json:"image_width,omitempty"`
|
||||
ImageHeight int32 `json:"image_height,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
// UserFieldValue holds a template variable value with multi-language support.
|
||||
type UserFieldValue struct {
|
||||
DefaultVal string `json:"default_val"`
|
||||
I18nVals map[string]string `json:"i18n_vals"` // keys: "zh_cn", "en_us", "ja_jp"
|
||||
}
|
||||
|
||||
// Resolve returns the localized value for the given language code.
|
||||
// Falls back to DefaultVal when the language key is missing or empty.
|
||||
func (v UserFieldValue) Resolve(lang string) string {
|
||||
if val, ok := v.I18nVals[lang]; ok && val != "" {
|
||||
return val
|
||||
}
|
||||
return v.DefaultVal
|
||||
}
|
||||
|
||||
// Signature represents a single mail signature returned by the API.
|
||||
type Signature struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SignatureType SignatureType `json:"signature_type"`
|
||||
SignatureDevice SignatureDevice `json:"signature_device"`
|
||||
Content string `json:"content"`
|
||||
Images []SignatureImage `json:"images,omitempty"`
|
||||
TemplateJSONKeys []string `json:"template_json_keys,omitempty"`
|
||||
UserFields map[string]UserFieldValue `json:"user_fields,omitempty"`
|
||||
}
|
||||
|
||||
// IsTenant returns true if this is a tenant/corporate signature with template variables.
|
||||
func (s *Signature) IsTenant() bool {
|
||||
return s.SignatureType == SignatureTypeTenant
|
||||
}
|
||||
|
||||
// HasTemplateVars returns true if the signature contains template variables that need interpolation.
|
||||
func (s *Signature) HasTemplateVars() bool {
|
||||
return len(s.TemplateJSONKeys) > 0
|
||||
}
|
||||
|
||||
// SignatureUsage indicates which signature is used by default for a given email address.
|
||||
type SignatureUsage struct {
|
||||
EmailAddress string `json:"email_address"`
|
||||
SendMailSignatureID string `json:"send_mail_signature_id"`
|
||||
ReplySignatureID string `json:"reply_signature_id"`
|
||||
}
|
||||
|
||||
// GetSignaturesResponse is the parsed response from the get_signatures API.
|
||||
type GetSignaturesResponse struct {
|
||||
Signatures []Signature `json:"signatures"`
|
||||
Usages []SignatureUsage `json:"usages"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// processCache holds per-mailbox cached responses.
|
||||
// CLI runs one command per process, so a package-level map is sufficient —
|
||||
// it is naturally scoped to a single Execute lifecycle.
|
||||
var processCache = map[string]*GetSignaturesResponse{}
|
||||
|
||||
func signaturesPath(mailboxID string) string {
|
||||
return "/open-apis/mail/v1/user_mailboxes/" + url.PathEscape(mailboxID) + "/settings/signatures"
|
||||
}
|
||||
|
||||
// ListAll fetches all signatures and usage info for a mailbox.
|
||||
// Results are cached per mailboxID within the current Execute lifecycle.
|
||||
func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesResponse, error) {
|
||||
if cached, ok := processCache[mailboxID]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get signatures: %w", err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
|
||||
}
|
||||
|
||||
var resp GetSignaturesResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
processCache[mailboxID] = &resp
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// List returns all signatures for a mailbox.
|
||||
func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error) {
|
||||
resp, err := ListAll(runtime, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Signatures, nil
|
||||
}
|
||||
|
||||
// Get returns a single signature by ID. Returns an error if not found.
|
||||
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
|
||||
resp, err := ListAll(runtime, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range resp.Signatures {
|
||||
if resp.Signatures[i].ID == signatureID {
|
||||
return &resp.Signatures[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("signature not found: %s", signatureID)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
"github.com/larksuite/cli/shortcuts/mail/signature"
|
||||
)
|
||||
|
||||
// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts.
|
||||
var signatureFlag = common.Flag{
|
||||
Name: "signature-id",
|
||||
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
|
||||
}
|
||||
|
||||
// signatureResult holds the pre-processed signature data ready for HTML injection.
|
||||
type signatureResult struct {
|
||||
ID string
|
||||
RenderedContent string
|
||||
Images []draftpkg.SignatureImage
|
||||
}
|
||||
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// Returns nil if signatureID is empty.
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// fromEmail is the --from address (may be an alias); used to match the correct
|
||||
// sender identity for template interpolation. Pass "" to use the primary address.
|
||||
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
|
||||
if signatureID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sig, err := signature.Get(runtime, mailboxID, signatureID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve sender info for template interpolation.
|
||||
lang := resolveLang(runtime)
|
||||
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
|
||||
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
|
||||
|
||||
// Download signature inline images. The file_key field contains a
|
||||
// direct download URL provided by the mail backend.
|
||||
var images []draftpkg.SignatureImage
|
||||
for _, img := range sig.Images {
|
||||
if img.DownloadURL == "" || img.CID == "" {
|
||||
continue
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
ContentType: ct,
|
||||
FileName: img.ImageName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return &signatureResult{
|
||||
ID: sig.ID,
|
||||
RenderedContent: rendered,
|
||||
Images: images,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// injectSignatureIntoBody inserts signature HTML into the body, before the quote block.
|
||||
// It removes any existing signature first, then places the new signature between
|
||||
// the user-authored content and the quote block (if any).
|
||||
// Returns the new full HTML body.
|
||||
func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
|
||||
if sig == nil {
|
||||
return bodyHTML
|
||||
}
|
||||
cleaned := draftpkg.RemoveSignatureHTML(bodyHTML)
|
||||
userContent, quote := draftpkg.SplitAtQuote(cleaned)
|
||||
sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent)
|
||||
return userContent + sigBlock + quote
|
||||
}
|
||||
|
||||
// addSignatureImagesToBuilder adds signature inline images to the EML builder.
|
||||
func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder {
|
||||
if sig == nil {
|
||||
return bld
|
||||
}
|
||||
for _, img := range sig.Images {
|
||||
cid := normalizeInlineCID(img.CID)
|
||||
if cid == "" {
|
||||
continue
|
||||
}
|
||||
bld = bld.AddInline(img.Data, img.ContentType, img.FileName, cid)
|
||||
}
|
||||
return bld
|
||||
}
|
||||
|
||||
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
|
||||
// resolveSenderInfo fetches send_as addresses and returns the name/email
|
||||
// for signature interpolation. If fromEmail is non-empty, it matches
|
||||
// that address in the sendable list (for alias/send_as scenarios);
|
||||
// otherwise falls back to the first (primary) address.
|
||||
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
addrs, ok := data["sendable_addresses"].([]interface{})
|
||||
if !ok || len(addrs) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
// If fromEmail is specified, find the matching address.
|
||||
if fromEmail != "" {
|
||||
for _, a := range addrs {
|
||||
m, ok := a.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e, _ := m["email_address"].(string)
|
||||
if strings.EqualFold(e, fromEmail) {
|
||||
n, _ := m["name"].(string)
|
||||
return n, e
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to the first sendable address (primary).
|
||||
first, ok := addrs[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
n, _ := first["name"].(string)
|
||||
e, _ := first["email_address"].(string)
|
||||
return n, e
|
||||
}
|
||||
|
||||
// downloadSignatureImage downloads a signature image by its direct URL.
|
||||
// Security: enforces https, does not send Bearer token (URL is pre-signed),
|
||||
// uses context timeout, and limits response size. Aligned with
|
||||
// downloadAttachmentContent in helpers.go.
|
||||
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
|
||||
u, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, "", fmt.Errorf("signature image download: URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
// Do NOT send Authorization: the download URL is pre-signed.
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signature image download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
|
||||
}
|
||||
if len(data) > maxSize {
|
||||
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" || ct == "application/octet-stream" {
|
||||
ct = contentTypeFromFilename(filename)
|
||||
}
|
||||
|
||||
return data, ct, nil
|
||||
}
|
||||
|
||||
func contentTypeFromFilename(name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// signatureCIDs returns the CID list from a signatureResult, for inline CID validation.
|
||||
func signatureCIDs(sig *signatureResult) []string {
|
||||
if sig == nil {
|
||||
return nil
|
||||
}
|
||||
cids := make([]string, 0, len(sig.Images))
|
||||
for _, img := range sig.Images {
|
||||
cid := normalizeInlineCID(img.CID)
|
||||
if cid != "" {
|
||||
cids = append(cids, cid)
|
||||
}
|
||||
}
|
||||
return cids
|
||||
}
|
||||
|
||||
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
|
||||
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
|
||||
if plainText && signatureID != "" {
|
||||
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func dataValidationBasePath(token string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
|
||||
validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func dataValidationSheetPath(token, sheetID string) string {
|
||||
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
|
||||
var typed []string
|
||||
if err := json.Unmarshal([]byte(value), &typed); err != nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
|
||||
}
|
||||
if typed == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
|
||||
}
|
||||
arr := make([]interface{}, len(typed))
|
||||
for i, s := range typed {
|
||||
arr[i] = s
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, common.FlagErrorf("--ranges must not be empty")
|
||||
}
|
||||
for i, r := range ranges {
|
||||
s, _ := r.(string)
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
|
||||
}
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(condValues) == 0 {
|
||||
return nil, common.FlagErrorf("--condition-values must not be empty")
|
||||
}
|
||||
|
||||
dv := map[string]interface{}{
|
||||
"conditionValues": condValues,
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("multiple") {
|
||||
opts["multipleValues"] = runtime.Bool("multiple")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("highlight") {
|
||||
opts["highlightValidData"] = runtime.Bool("highlight")
|
||||
}
|
||||
if runtime.Str("colors") != "" {
|
||||
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) != len(condValues) {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
|
||||
}
|
||||
opts["colors"] = colors
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
dv["options"] = opts
|
||||
}
|
||||
|
||||
return dv, nil
|
||||
}
|
||||
|
||||
// SheetSetDropdown sets dropdown list validation on a range.
|
||||
var SheetSetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-dropdown",
|
||||
Description: "Set dropdown list on a cell range",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateDropdown updates dropdown list settings for given ranges.
|
||||
var SheetUpdateDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dropdown",
|
||||
Description: "Update dropdown list settings",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateRangesFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
|
||||
Body(map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetDropdown queries dropdown list settings for a range.
|
||||
var SheetGetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-dropdown",
|
||||
Description: "Get dropdown list settings for a range",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
|
||||
Set("token", token).Set("range", runtime.Str("range"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
}, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
|
||||
var SheetDeleteDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dropdown",
|
||||
Description: "Delete dropdown list from cell ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateRangesFlag(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── SetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": "not-json",
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mixed types", `["ok", 1, null]`},
|
||||
{"all numbers", `[1, 2, 3]`},
|
||||
{"null literal", `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": tc.input,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
|
||||
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "bad-json",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "A2:A100", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `[]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
|
||||
"colors": `["#FF0000"]`,
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors length") {
|
||||
t.Fatalf("expected length mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": true, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"dataValidationType":"list"`) {
|
||||
t.Fatalf("DryRun missing dataValidationType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
|
||||
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
dv, _ := body["dataValidation"].(map[string]interface{})
|
||||
opts, _ := dv["options"].(map[string]interface{})
|
||||
if opts["multipleValues"] != true {
|
||||
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
|
||||
}
|
||||
if opts["highlightValidData"] != true {
|
||||
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": "not-json", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `[]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "{not-array}",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `sheet1`) {
|
||||
t.Fatalf("DryRun missing sheet_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["new1","new2"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["opt1"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"dataValidationType": "list",
|
||||
"conditionValues": []interface{}{"opt1", "opt2"},
|
||||
"ranges": []interface{}{"s1!A2:A100"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "dataValidations") {
|
||||
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidationRanges`) {
|
||||
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"rangeResults": []interface{}{
|
||||
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "rangeResults") {
|
||||
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
dvRanges, _ := body["dataValidationRanges"].([]interface{})
|
||||
if len(dvRanges) != 2 {
|
||||
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suppress unused import for bytes in case the test helpers already import it
|
||||
var _ = (*bytes.Buffer)(nil)
|
||||
@@ -36,9 +36,5 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// presentationRef holds a parsed --presentation input.
|
||||
//
|
||||
// Slides shortcuts accept three input shapes:
|
||||
// - a raw xml_presentation_id token
|
||||
// - a slides URL like https://<host>/slides/<token>
|
||||
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
|
||||
type presentationRef struct {
|
||||
Kind string // "slides" | "wiki"
|
||||
Token string
|
||||
}
|
||||
|
||||
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
|
||||
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
|
||||
// obtain the real xml_presentation_id and verify obj_type=slides.
|
||||
func parsePresentationRef(input string) (presentationRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
|
||||
}
|
||||
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
|
||||
// appear as a prefix of the URL path. Substring matching previously let
|
||||
// e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc".
|
||||
if strings.Contains(raw, "://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Path == "" {
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
|
||||
return presentationRef{Kind: "slides", Token: token}, nil
|
||||
}
|
||||
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
|
||||
return presentationRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
// Non-URL input must be a bare token — anything with path/query/fragment
|
||||
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
|
||||
// get silently accepted.
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
return presentationRef{Kind: "slides", Token: raw}, nil
|
||||
}
|
||||
|
||||
// tokenAfterPathPrefix extracts the first path segment after prefix from path.
|
||||
// Returns ("", false) if path doesn't start with prefix or the segment is empty.
|
||||
func tokenAfterPathPrefix(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
|
||||
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
|
||||
// must resolve to obj_type=slides.
|
||||
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
|
||||
switch ref.Kind {
|
||||
case "slides":
|
||||
return ref.Token, nil
|
||||
case "wiki":
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType != "slides" {
|
||||
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
|
||||
}
|
||||
return objToken, nil
|
||||
default:
|
||||
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
|
||||
// The "@" prefix is the magic marker for "this is a local file path; upload it and
|
||||
// replace with file_token".
|
||||
//
|
||||
// Match groups:
|
||||
//
|
||||
// 1: opening quote character (so we can replace symmetrically)
|
||||
// 2: the path string (everything inside the quotes after the leading @)
|
||||
//
|
||||
// We deliberately scope to <img ... src="@..."> rather than any src= so other
|
||||
// schema elements (like icon/iconType) aren't accidentally rewritten.
|
||||
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
|
||||
// around `=`); without it we'd silently leave such placeholders unrewritten.
|
||||
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
|
||||
|
||||
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
|
||||
// referenced via <img src="@path"> in the given slide XML strings.
|
||||
//
|
||||
// Order is preserved (first occurrence wins) so dry-run / progress messages are
|
||||
// stable across runs.
|
||||
func extractImagePlaceholderPaths(slideXMLs []string) []string {
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, xml := range slideXMLs {
|
||||
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
|
||||
for _, m := range matches {
|
||||
if m[1] != m[3] {
|
||||
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
|
||||
// so we filter it here. Treat as malformed XML and skip.
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(m[2])
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
|
||||
// XML by looking up each path in tokens. Paths missing from the map are left
|
||||
// untouched (callers should ensure the map is complete).
|
||||
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
|
||||
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
|
||||
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 4 {
|
||||
return match
|
||||
}
|
||||
quote, path, closeQuote := sub[1], sub[2], sub[3]
|
||||
if quote != closeQuote {
|
||||
// Mismatched quotes — see extractImagePlaceholderPaths.
|
||||
return match
|
||||
}
|
||||
token, ok := tokens[strings.TrimSpace(path)]
|
||||
if !ok {
|
||||
return match
|
||||
}
|
||||
// Replace only the `"@<path>"` segment (quotes inclusive) so any
|
||||
// surrounding attrs and whitespace around `=` stay intact. Looking up
|
||||
// by the literal `@<path>"` (with closing quote) avoids accidentally
|
||||
// matching the same path elsewhere in the tag.
|
||||
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
|
||||
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
|
||||
return strings.Replace(match, oldQuoted, newQuoted, 1)
|
||||
})
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePresentationRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"},
|
||||
{name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"},
|
||||
{name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"},
|
||||
{name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"},
|
||||
{name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"},
|
||||
{name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"},
|
||||
{name: "empty", input: "", wantErr: "cannot be empty"},
|
||||
{name: "blank", input: " ", wantErr: "cannot be empty"},
|
||||
{name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"},
|
||||
{name: "unsupported path", input: "foo/bar", wantErr: "unsupported"},
|
||||
// Regression: /slides/ inside a query string must NOT be treated as a slides marker.
|
||||
{name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"},
|
||||
// Regression: /wiki/ as a path segment but not a prefix must not match.
|
||||
{name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"},
|
||||
// Regression: bare relative path containing wiki/ is not a wiki ref.
|
||||
{name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parsePresentationRef(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
|
||||
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImagePlaceholderPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no placeholders",
|
||||
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single placeholder",
|
||||
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
|
||||
want: []string{"./pic.png"},
|
||||
},
|
||||
{
|
||||
name: "single quotes",
|
||||
in: []string{`<img src='@./a.png'/>`},
|
||||
want: []string{"./a.png"},
|
||||
},
|
||||
{
|
||||
name: "dedup across slides",
|
||||
in: []string{
|
||||
`<slide><data><img src="@./shared.png"/></data></slide>`,
|
||||
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
|
||||
},
|
||||
want: []string{"./shared.png", "./other.png"},
|
||||
},
|
||||
{
|
||||
name: "ignores non-img src",
|
||||
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
|
||||
want: []string{"./real.png"},
|
||||
},
|
||||
{
|
||||
name: "preserves order of first occurrence",
|
||||
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
|
||||
want: []string{"b.png", "a.png"},
|
||||
},
|
||||
{
|
||||
// Regression: Go RE2 has no backreferences, so the regex captures
|
||||
// opening and closing quotes independently. Mismatched pairs must
|
||||
// be filtered out post-match instead of producing bogus paths.
|
||||
name: "rejects mismatched quotes",
|
||||
in: []string{`<img src="@./oops.png'/>`},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// Regression: XML allows whitespace around `=`; placeholders in
|
||||
// `src = "@..."` form must still be detected.
|
||||
name: "tolerates whitespace around equals",
|
||||
in: []string{`<img src = "@./spaced.png" />`},
|
||||
want: []string{"./spaced.png"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := extractImagePlaceholderPaths(tt.in)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceImagePlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tokens := map[string]string{
|
||||
"./pic.png": "tok_abc",
|
||||
"./b.png": "tok_b",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single replacement preserves siblings",
|
||||
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
|
||||
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
|
||||
},
|
||||
{
|
||||
name: "multiple replacements",
|
||||
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
|
||||
want: `<img src="tok_abc"/><img src="tok_b"/>`,
|
||||
},
|
||||
{
|
||||
name: "single quotes",
|
||||
in: `<img src='@./pic.png'/>`,
|
||||
want: `<img src='tok_abc'/>`,
|
||||
},
|
||||
{
|
||||
name: "leaves unknown placeholder untouched",
|
||||
in: `<img src="@./missing.png"/>`,
|
||||
want: `<img src="@./missing.png"/>`,
|
||||
},
|
||||
{
|
||||
name: "leaves http url alone",
|
||||
in: `<img src="https://x.com/a.png"/>`,
|
||||
want: `<img src="https://x.com/a.png"/>`,
|
||||
},
|
||||
{
|
||||
name: "leaves bare token alone",
|
||||
in: `<img src="existing_token"/>`,
|
||||
want: `<img src="existing_token"/>`,
|
||||
},
|
||||
{
|
||||
// Regression: placeholders with whitespace around `=` must be
|
||||
// rewritten too (XML permits the form). Surrounding whitespace
|
||||
// is preserved so the rewritten attribute reads naturally.
|
||||
name: "tolerates whitespace around equals",
|
||||
in: `<img src = "@./pic.png" topLeftX="10"/>`,
|
||||
want: `<img src = "tok_abc" topLeftX="10"/>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := replaceImagePlaceholders(tt.in, tokens)
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q\nwant %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,5 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -28,15 +27,10 @@ var SlidesCreate = common.Shortcut{
|
||||
Description: "Create a Lark Slides presentation",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// docs:document.media:upload is required by the @-placeholder upload path.
|
||||
// Declared up-front (matching the convention used by other multi-API shortcuts
|
||||
// like wiki_move) so the pre-flight check fails fast and lark-cli's
|
||||
// auth login --scope hint guides the user, instead of leaving an orphaned
|
||||
// empty presentation when the in-flight upload 403s.
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
|
||||
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if slidesStr := runtime.Str("slides"); slidesStr != "" {
|
||||
@@ -47,21 +41,6 @@ var SlidesCreate = common.Shortcut{
|
||||
if len(slides) > maxSlidesPerCreate {
|
||||
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
|
||||
}
|
||||
// Validate placeholder paths up front so we don't create a presentation
|
||||
// only to fail mid-way on a missing local file.
|
||||
for _, path := range extractImagePlaceholderPaths(slides) {
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return common.FlagErrorf("--slides @%s: must be a regular file", path)
|
||||
}
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
|
||||
path, common.FormatSize(stat.Size()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -82,32 +61,16 @@ var SlidesCreate = common.Shortcut{
|
||||
var slides []string
|
||||
_ = json.Unmarshal([]byte(slidesStr), &slides)
|
||||
n := len(slides)
|
||||
placeholders := extractImagePlaceholderPaths(slides)
|
||||
total := n + 1 + len(placeholders)
|
||||
total := n + 1
|
||||
|
||||
descSuffix := ""
|
||||
if len(placeholders) > 0 {
|
||||
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(placeholders))
|
||||
}
|
||||
dry.Desc(fmt.Sprintf("Create presentation%s + add %d slide(s)", descSuffix, n)).
|
||||
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
|
||||
POST("/open-apis/slides_ai/v1/xml_presentations").
|
||||
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
|
||||
Body(createBody)
|
||||
|
||||
// Upload steps come right after creation so they can use the new
|
||||
// presentation_id as parent_node.
|
||||
for i, path := range placeholders {
|
||||
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
|
||||
}
|
||||
|
||||
slideStepStart := 2 + len(placeholders)
|
||||
slideDescSuffix := ""
|
||||
if len(placeholders) > 0 {
|
||||
slideDescSuffix = " (img placeholders auto-replaced)"
|
||||
}
|
||||
for i, slideXML := range slides {
|
||||
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
|
||||
Desc(fmt.Sprintf("[%d/%d] Add slide %d%s", slideStepStart+i, total, i+1, slideDescSuffix)).
|
||||
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": slideXML},
|
||||
})
|
||||
@@ -158,23 +121,6 @@ var SlidesCreate = common.Shortcut{
|
||||
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
|
||||
|
||||
if len(slides) > 0 {
|
||||
// Step 1.5: Upload any @path placeholders, then rewrite slide XML
|
||||
// with the resulting file_tokens. Uploads run after creation so
|
||||
// they can use the new presentation_id as parent_node.
|
||||
placeholders := extractImagePlaceholderPaths(slides)
|
||||
if len(placeholders) > 0 {
|
||||
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
|
||||
err, presentationID, uploaded)
|
||||
}
|
||||
for i := range slides {
|
||||
slides[i] = replaceImagePlaceholders(slides[i], tokens)
|
||||
}
|
||||
result["images_uploaded"] = uploaded
|
||||
}
|
||||
|
||||
slideURL := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
@@ -259,33 +205,6 @@ func buildPresentationXML(title string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// uploadSlidesPlaceholders uploads each unique placeholder path against the
|
||||
// presentation and returns the path→file_token map. The second return value is
|
||||
// the number of files successfully uploaded before any error, so callers can
|
||||
// surface progress in the failure message.
|
||||
func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID string, paths []string) (map[string]string, int, error) {
|
||||
tokens := make(map[string]string, len(paths))
|
||||
for i, path := range paths {
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
|
||||
}
|
||||
fileName := filepath.Base(path)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
|
||||
i+1, len(paths), fileName, common.FormatSize(stat.Size()))
|
||||
|
||||
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
|
||||
if err != nil {
|
||||
return tokens, i, fmt.Errorf("@%s: %w", path, err)
|
||||
}
|
||||
tokens[path] = token
|
||||
}
|
||||
return tokens, len(paths), nil
|
||||
}
|
||||
|
||||
// xmlEscape escapes special XML characters in text content.
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
|
||||
@@ -6,7 +6,6 @@ package slides
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -652,175 +651,3 @@ func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]i
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithImagePlaceholders verifies @path placeholders are uploaded
|
||||
// once each (with dedup) and replaced with file_tokens before slide.create runs.
|
||||
//
|
||||
// Not parallel: uses os.Chdir to pin local file paths to a temp dir.
|
||||
func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("a.png", []byte("aa"), 0o644); err != nil {
|
||||
t.Fatalf("write a.png: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("b.png", []byte("bb"), 0o644); err != nil {
|
||||
t.Fatalf("write b.png: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_img",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Two distinct images → two upload calls. a.png is referenced twice but
|
||||
// must be uploaded only once.
|
||||
uploadStubA := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_a"}},
|
||||
}
|
||||
uploadStubB := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_b"}},
|
||||
}
|
||||
reg.Register(uploadStubA)
|
||||
reg.Register(uploadStubB)
|
||||
|
||||
// Slide stubs: capture the rewritten slide content to assert tokens were
|
||||
// actually substituted into the XML.
|
||||
slideStub1 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}},
|
||||
}
|
||||
slideStub2 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s2", "revision_id": 3}},
|
||||
}
|
||||
reg.Register(slideStub1)
|
||||
reg.Register(slideStub2)
|
||||
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
|
||||
|
||||
slidesJSON := `[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"30\"/></data></slide>"
|
||||
]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Img test",
|
||||
"--slides", slidesJSON,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["images_uploaded"] != float64(2) {
|
||||
t.Fatalf("images_uploaded = %v, want 2 (a.png deduped)", data["images_uploaded"])
|
||||
}
|
||||
if data["slides_added"] != float64(2) {
|
||||
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
|
||||
}
|
||||
|
||||
// Assert each slide.create body uses tokens (not @path placeholders), and
|
||||
// that both upload tokens reach at least one slide so a buggy mapping
|
||||
// where `@b.png` got rewritten to `tok_a` would still fail.
|
||||
hasTokB := false
|
||||
for _, stub := range []*httpmock.Stub{slideStub1, slideStub2} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
slide, _ := body["slide"].(map[string]interface{})
|
||||
content, _ := slide["content"].(string)
|
||||
if strings.Contains(content, "@a.png") || strings.Contains(content, "@b.png") {
|
||||
t.Fatalf("slide content still contains placeholder: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "tok_a") {
|
||||
t.Fatalf("slide content missing tok_a: %s", content)
|
||||
}
|
||||
if strings.Contains(content, "tok_b") {
|
||||
hasTokB = true
|
||||
}
|
||||
}
|
||||
if !hasTokB {
|
||||
t.Fatal("expected at least one slide body to contain tok_b")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreatePlaceholderFileMissing verifies validation rejects a missing local file
|
||||
// up front, before the presentation is created.
|
||||
func TestSlidesCreatePlaceholderFileMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// No HTTP mocks registered — Validate must reject before any API call.
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
slidesJSON := `["<slide><data><img src=\"@./missing.png\"/></data></slide>"]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "missing img",
|
||||
"--slides", slidesJSON,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing placeholder file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing.png") {
|
||||
t.Fatalf("err = %v, want mention of missing.png", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithPlaceholdersDryRun verifies dry-run lists upload steps
|
||||
// with placeholder files counted into the total.
|
||||
func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("p1.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("p2.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
slidesJSON := `["<slide><data><img src=\"@p1.png\"/><img src=\"@p2.png\"/></data></slide>"]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "dry imgs",
|
||||
"--slides", slidesJSON,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Bookend step markers: [1/4] = create presentation, [4/4] = add slide 1.
|
||||
// Upload steps in between use the helper's own [N] labels (no /total).
|
||||
for _, marker := range []string{"[1/4]", "[4/4]"} {
|
||||
if !strings.Contains(out, marker) {
|
||||
t.Fatalf("dry-run missing %s, got: %s", marker, out)
|
||||
}
|
||||
}
|
||||
if strings.Count(out, "upload_all") != 2 {
|
||||
t.Fatalf("dry-run should contain 2 upload_all calls, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, slidesMediaParentType) {
|
||||
t.Fatalf("dry-run missing parent_type %q, got: %s", slidesMediaParentType, out)
|
||||
}
|
||||
if !strings.Contains(out, "Create presentation + upload 2 image(s)") {
|
||||
t.Fatalf("dry-run header should describe upload count, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// slidesMediaParentType is the only parent_type the slides backend accepts for
|
||||
// media uploaded against an xml_presentation. Verified empirically:
|
||||
// `slide_image` returns 1061001 unknown error, `slides_image` / `slides_file`
|
||||
// return 1061002 params error, but `slide_file` returns a valid file_token
|
||||
// that can be used as <img src="..."> in slide XML.
|
||||
//
|
||||
// NOTE: `slide_file` is only accepted by the single-part upload_all endpoint.
|
||||
// The multipart upload_prepare endpoint rejects it (99992402 field validation
|
||||
// failed), so slides image uploads are capped at 20 MB.
|
||||
const slidesMediaParentType = "slide_file"
|
||||
|
||||
// SlidesMediaUpload uploads a local image to drive media against a slides
|
||||
// presentation and returns the file_token. The token can be used as the value
|
||||
// of <img src="..."> in slide XML.
|
||||
//
|
||||
// This is the atomic building block for getting a local image into a slides
|
||||
// deck. Higher-level shortcuts (e.g. +create with @path placeholders) reuse
|
||||
// the same upload helpers.
|
||||
var SlidesMediaUpload = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image to a slides presentation and return the file_token (use as <img src=...>)",
|
||||
Risk: "write",
|
||||
// wiki:node:read is required by the wiki-URL resolution path. Declared
|
||||
// up-front (matching the convention used by other multi-API shortcuts) so
|
||||
// users without it get the standard auth login --scope hint at pre-flight.
|
||||
Scopes: []string{"docs:document.media:upload", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local image path (max 20 MB)", Required: true},
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
filePath := runtime.Str("file")
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
parentNode := ref.Token
|
||||
stepBase := 1
|
||||
if ref.Kind == "wiki" {
|
||||
parentNode = "<resolved_slides_token>"
|
||||
stepBase = 2
|
||||
dry.Desc("2-step orchestration: resolve wiki → upload media").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Upload local file to slides presentation")
|
||||
}
|
||||
appendSlidesUploadDryRun(dry, filePath, parentNode, stepBase)
|
||||
return dry.Set("presentation_id", ref.Token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
filepath.Base(filePath), common.FormatSize(stat.Size()))
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> presentation %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(presentationID))
|
||||
|
||||
fileToken, err := uploadSlidesMedia(runtime, filePath, fileName, stat.Size(), presentationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"presentation_id": presentationID,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// uploadSlidesMedia is the shared upload helper used by both +media-upload and
|
||||
// the +create placeholder pipeline. Always uses parent_type=slide_file with the
|
||||
// presentation_id as parent_node — verified to be the only working combo.
|
||||
//
|
||||
// Callers must ensure fileSize ≤ MaxDriveMediaUploadSinglePartSize (20 MB)
|
||||
// because the multipart upload API does not accept parent_type=slide_file.
|
||||
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
|
||||
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
fileName, common.FormatSize(fileSize))
|
||||
}
|
||||
parent := presentationID
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: slidesMediaParentType,
|
||||
ParentNode: &parent,
|
||||
})
|
||||
}
|
||||
|
||||
// appendSlidesUploadDryRun renders the upload_all step for a single file.
|
||||
func appendSlidesUploadDryRun(d *common.DryRunAPI, filePath, parentNode string, step int) {
|
||||
d.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("[%d] Upload local file (max 20 MB)", step)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": slidesMediaParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
})
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestSlidesMediaUploadBasic verifies the happy path: token + presentation_id
|
||||
// with a real (small) local file.
|
||||
func TestSlidesMediaUploadBasic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_xyz"},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "img.png",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["file_token"] != "file_tok_xyz" {
|
||||
t.Fatalf("file_token = %v, want file_tok_xyz", data["file_token"])
|
||||
}
|
||||
if data["presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("presentation_id = %v, want pres_abc", data["presentation_id"])
|
||||
}
|
||||
if data["file_name"] != "img.png" {
|
||||
t.Fatalf("file_name = %v, want img.png", data["file_name"])
|
||||
}
|
||||
if data["size"] != float64(len("png-bytes")) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len("png-bytes"))
|
||||
}
|
||||
|
||||
body := decodeMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "pres_abc" {
|
||||
t.Fatalf("parent_node = %q, want pres_abc", got)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadFromSlidesURL verifies that a slides URL is accepted
|
||||
// and the path-segment token is used as parent_node.
|
||||
func TestSlidesMediaUploadFromSlidesURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("p.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "p.png",
|
||||
"--presentation", "https://x.feishu.cn/slides/url_token_123?from=share",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_node"]; got != "url_token_123" {
|
||||
t.Fatalf("parent_node = %q, want url_token_123", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["presentation_id"] != "url_token_123" {
|
||||
t.Fatalf("presentation_id = %v, want url_token_123", data["presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadFromWikiURL verifies wiki URL → get_node lookup is performed
|
||||
// and the resolved obj_token is used as parent_node.
|
||||
func TestSlidesMediaUploadFromWikiURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "real_pres_id",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "w.png",
|
||||
"--presentation", "https://x.feishu.cn/wiki/wikcn_xyz",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_node"]; got != "real_pres_id" {
|
||||
t.Fatalf("parent_node = %q, want real_pres_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadWikiWrongType verifies wiki resolution rejects non-slides docs.
|
||||
func TestSlidesMediaUploadWikiWrongType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_tok",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "w.png",
|
||||
"--presentation", "https://x.feishu.cn/wiki/wikcn",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-slides wiki node")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docx") {
|
||||
t.Fatalf("err = %v, want mention of resolved obj_type", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadFileNotFound verifies a missing local file fails fast.
|
||||
func TestSlidesMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "missing.png",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
|
||||
t.Fatalf("err = %v, want file-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref.
|
||||
func TestSlidesMediaUploadInvalidPresentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "any.png",
|
||||
"--presentation", "https://x.feishu.cn/docx/foo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for unsupported presentation URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("err = %v, want 'unsupported' mention", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesMediaUploadDryRun verifies dry-run prints the upload step.
|
||||
func TestSlidesMediaUploadDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("dry.png", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--file", "dry.png",
|
||||
"--presentation", "pres_abc",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should mention upload_all, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, slidesMediaParentType) {
|
||||
t.Fatalf("dry-run should mention parent_type %q, got: %s", slidesMediaParentType, out)
|
||||
}
|
||||
}
|
||||
|
||||
// runSlidesShortcut mounts and executes a slides shortcut with the given args.
|
||||
func runSlidesShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, sc common.Shortcut, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "slides"}
|
||||
sc.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// decodeShortcutData parses the JSON envelope and returns the data map.
|
||||
func decodeShortcutData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// withSlidesTestWorkingDir chdirs to dir for this test (restored on cleanup).
|
||||
// Not compatible with t.Parallel — chdir is process-wide.
|
||||
func withSlidesTestWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(cwd)
|
||||
})
|
||||
}
|
||||
|
||||
type capturedMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data := readAll(t, part)
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, r interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
buf.Write(tmp[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -223,7 +223,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
SetAncestorTask,
|
||||
CommentTask,
|
||||
CompleteTask,
|
||||
ReopenTask,
|
||||
@@ -231,11 +230,7 @@ func Shortcuts() []common.Shortcut {
|
||||
FollowersTask,
|
||||
ReminderTask,
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
AddTaskToTasklist,
|
||||
MembersTasklist,
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
relatedTasksDefaultPageLimit = 20
|
||||
relatedTasksMaxPageLimit = 40
|
||||
relatedTasksPageSize = 100
|
||||
)
|
||||
|
||||
var GetRelatedTasks = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+get-related-tasks",
|
||||
Description: "list tasks related to me",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
|
||||
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
|
||||
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
params["completed"] = false
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/task_v2/list_related_task").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
queryParams.Set("completed", "false")
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = relatedTasksDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
if pageLimit > relatedTasksMaxPageLimit {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
|
||||
var allItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "list related tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", lastPageToken)
|
||||
}
|
||||
|
||||
userOpenID := runtime.UserOpenId()
|
||||
filtered := make([]map[string]interface{}, 0, len(allItems))
|
||||
for _, item := range allItems {
|
||||
task, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if runtime.Bool("created-by-me") {
|
||||
creator, _ := task["creator"].(map[string]interface{})
|
||||
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, outputRelatedTask(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": filtered,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
|
||||
if len(filtered) == 0 {
|
||||
fmt.Fprintln(w, "No related tasks found.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
|
||||
members, _ := task["members"].([]interface{})
|
||||
for _, member := range members {
|
||||
memberObj, _ := member.(map[string]interface{})
|
||||
role, _ := memberObj["role"].(string)
|
||||
id, _ := memberObj["id"].(string)
|
||||
if strings.EqualFold(role, "follower") && id == userOpenID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestTaskFollowedBy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
userOpenID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
map[string]interface{}{"id": "ou_2", "role": "follower"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_3",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := taskFollowedBy(tt.task, tt.userOpenID)
|
||||
if got != tt.want {
|
||||
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with page token and incomplete filter",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("include-complete", "false")
|
||||
_ = cmd.Flags().Set("page-token", "pt_001")
|
||||
},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
|
||||
},
|
||||
{
|
||||
name: "default query params",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Bool("include-complete", true, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
out := GetRelatedTasks.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json created by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "done",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123",
|
||||
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
|
||||
},
|
||||
{
|
||||
name: "pretty pagination followed by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_2",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
"summary": "Task One",
|
||||
"url": "https://example.com/task-1",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "page_token=pt_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-2",
|
||||
"summary": "Task Two",
|
||||
"url": "https://example.com/task-2",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Task One", "Task Two"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := GetRelatedTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func splitAndTrimCSV(input string) []string {
|
||||
parts := strings.Split(input, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startMillis, endMillis string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startMillis = startSec + "000"
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endMillis = endSec + "000"
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startMillis, endMillis, nil
|
||||
}
|
||||
|
||||
func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func formatTaskDateTimeMillis(msStr string) string {
|
||||
if msStr == "" || msStr == "0" {
|
||||
return ""
|
||||
}
|
||||
ms, err := strconv.ParseInt(msStr, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format(time.DateTime)
|
||||
}
|
||||
|
||||
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
|
||||
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
|
||||
out["updated_at"] = updated
|
||||
}
|
||||
}
|
||||
if dueObj, ok := task["due"].(map[string]interface{}); ok {
|
||||
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
|
||||
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
|
||||
out["due_at"] = dueAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"description": task["description"],
|
||||
"status": task["status"],
|
||||
"source": task["source"],
|
||||
"mode": task["mode"],
|
||||
"subtask_count": task["subtask_count"],
|
||||
"tasklists": task["tasklists"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if creator, ok := task["creator"].(map[string]interface{}); ok {
|
||||
out["creator"] = creator
|
||||
}
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
out["members"] = members
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
|
||||
timeRange := map[string]interface{}{}
|
||||
if start != "" {
|
||||
timeRange["start_time"] = start
|
||||
}
|
||||
if end != "" {
|
||||
timeRange["end_time"] = end
|
||||
}
|
||||
if len(timeRange) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{key: timeRange}
|
||||
}
|
||||
|
||||
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
|
||||
if strings.TrimSpace(query) != "" {
|
||||
return nil
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
return nil
|
||||
}
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
|
||||
}
|
||||
|
||||
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
|
||||
var b strings.Builder
|
||||
for i, item := range items {
|
||||
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
|
||||
if status, _ := item["status"].(string); status != "" {
|
||||
fmt.Fprintf(&b, " Status: %s\n", status)
|
||||
}
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(&b, " Created: %s\n", created)
|
||||
}
|
||||
if completed, _ := item["completed_at"].(string); completed != "" {
|
||||
fmt.Fprintf(&b, " Completed: %s\n", completed)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(&b, " URL: %s\n", urlVal)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if hasMore && pageToken != "" {
|
||||
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitAndTrimCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
|
||||
{name: "empty input", input: "", want: []string{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := splitAndTrimCSV(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "with timestamps and due",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "summary",
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"created_at": "1775174400000",
|
||||
"due": map[string]interface{}{
|
||||
"timestamp": "1775174400000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with completed and updated",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "done",
|
||||
"url": "https://example.com/task-456",
|
||||
"completed_at": "1775174400000",
|
||||
"updated_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := outputTaskSummary(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected summary output: %#v", got)
|
||||
}
|
||||
if got["url"] == "" {
|
||||
t.Fatalf("expected url in output, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
for _, tt := range timeTests {
|
||||
t.Run("parse:"+tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeMillis(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if tt.wantStart == "" && start != "" {
|
||||
t.Fatalf("start = %q, want empty", start)
|
||||
}
|
||||
if tt.wantEnd == "" && end != "" {
|
||||
t.Fatalf("end = %q, want empty", end)
|
||||
}
|
||||
if tt.wantStart == "non-empty" && start == "" {
|
||||
t.Fatalf("start should not be empty")
|
||||
}
|
||||
if tt.wantEnd == "non-empty" && end == "" {
|
||||
t.Fatalf("end should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
filterTests := []struct {
|
||||
name string
|
||||
query string
|
||||
filter map[string]interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
|
||||
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
|
||||
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
|
||||
}
|
||||
for _, tt := range filterTests {
|
||||
t.Run("filter:"+tt.name, func(t *testing.T) {
|
||||
err := requireSearchFilter(tt.query, tt.filter, "search")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
|
||||
outputTests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "todo",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"creator": map[string]interface{}{"id": "ou_1"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
|
||||
"created_at": "1775174400000",
|
||||
"completed_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "Minimal",
|
||||
"url": "https://example.com/task-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range outputTests {
|
||||
t.Run("output:"+tt.name, func(t *testing.T) {
|
||||
got := outputRelatedTask(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected related task output: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rangeTests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "empty range", start: "", end: "", wantNil: true},
|
||||
{name: "full range", start: "1", end: "2", wantNil: false},
|
||||
}
|
||||
for _, tt := range rangeTests {
|
||||
t.Run("range:"+tt.name, func(t *testing.T) {
|
||||
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Fatalf("expected nil, got %#v", got)
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Fatalf("expected range filter, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []map[string]interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "includes next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
|
||||
},
|
||||
hasMore: true,
|
||||
pageToken: "pt_123",
|
||||
wantParts: []string{"Related Task", "Next page token: pt_123"},
|
||||
},
|
||||
{
|
||||
name: "without next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-456", "summary": "Another Task"},
|
||||
},
|
||||
hasMore: false,
|
||||
pageToken: "",
|
||||
wantParts: []string{"Another Task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range timeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeRFC3339(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
|
||||
}
|
||||
if tt.wantStart == "rfc3339" {
|
||||
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
|
||||
t.Fatalf("expected RFC3339 start, got %q", start)
|
||||
}
|
||||
} else if start != tt.wantStart {
|
||||
t.Fatalf("unexpected start: %q", start)
|
||||
}
|
||||
if tt.wantEnd == "rfc3339" {
|
||||
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
|
||||
t.Fatalf("expected RFC3339 end, got %q", end)
|
||||
}
|
||||
} else if end != tt.wantEnd {
|
||||
t.Fatalf("unexpected end: %q", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
taskSearchDefaultPageLimit = 20
|
||||
taskSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+search",
|
||||
Description: "search tasks",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
|
||||
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
|
||||
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
|
||||
{Name: "follower", Desc: "follower open_ids, comma-separated"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTaskSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = taskSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > taskSearchMaxPageLimit {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
taskID, _ := itemMap["id"].(string)
|
||||
if taskID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := getTaskDetail(runtime, taskID)
|
||||
if err != nil {
|
||||
metaData, _ := itemMap["meta_data"].(map[string]interface{})
|
||||
appLink, _ := metaData["app_link"].(string)
|
||||
enriched = append(enriched, map[string]interface{}{
|
||||
"guid": taskID,
|
||||
"url": truncateTaskURL(appLink),
|
||||
})
|
||||
continue
|
||||
}
|
||||
enriched = append(enriched, outputTaskSummary(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": enriched,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
return
|
||||
}
|
||||
for i, item := range enriched {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(w, " Created: %s\n", created)
|
||||
}
|
||||
if dueAt, _ := item["due_at"].(string); dueAt != "" {
|
||||
fmt.Fprintf(w, " Due: %s\n", dueAt)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["creator_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
|
||||
filter["assignee_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
|
||||
filter["follower_ids"] = ids
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("completed") {
|
||||
filter["is_completed"] = runtime.Bool("completed")
|
||||
}
|
||||
if dueRange := runtime.Str("due"); dueRange != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(dueRange)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
|
||||
}
|
||||
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
|
||||
mergeIntoFilter(filter, dueFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTaskSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "query creator due and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "release")
|
||||
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
|
||||
_ = cmd.Flags().Set("completed", "true")
|
||||
_ = cmd.Flags().Set("due", "-1d,+1d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_123")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := dueTime["start_time"].(string)
|
||||
endTime, _ := dueTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected due_time: %#v", dueTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "assignee follower and incomplete",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("assignee", "ou_assignee")
|
||||
_ = cmd.Flags().Set("follower", "ou_follower")
|
||||
_ = cmd.Flags().Set("completed", "false")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
if filter["is_completed"] != false {
|
||||
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTaskSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("due", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTask.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
|
||||
},
|
||||
{
|
||||
name: "fallback to app link",
|
||||
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-999",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasks found."},
|
||||
},
|
||||
{
|
||||
name: "pretty with next page token",
|
||||
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-321",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTask
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SetAncestorTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+set-ancestor",
|
||||
Description: "set or clear a task ancestor",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task guid to update", Required: true},
|
||||
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
taskID := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
|
||||
QueryParams: queryParams,
|
||||
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
|
||||
}
|
||||
}
|
||||
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{
|
||||
"guid": taskID,
|
||||
},
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
|
||||
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
|
||||
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
|
||||
if ancestorID == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ancestor_guid": ancestorID,
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildSetAncestorBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestorID string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
|
||||
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildSetAncestorBody(tt.ancestorID)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
|
||||
}
|
||||
for k, want := range tt.want {
|
||||
if got[k] != want {
|
||||
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskID string
|
||||
ancestor string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with ancestor",
|
||||
taskID: "task-123",
|
||||
ancestor: "task-456",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
|
||||
},
|
||||
{
|
||||
name: "clear ancestor",
|
||||
taskID: "task-123",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("ancestor-id", "", "")
|
||||
_ = cmd.Flags().Set("task-id", tt.taskID)
|
||||
if tt.ancestor != "" {
|
||||
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
|
||||
out := SetAncestorTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json output with ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`},
|
||||
},
|
||||
{
|
||||
name: "pretty output clears ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
|
||||
},
|
||||
{
|
||||
name: "api-level error (code!=0) returns error",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10003,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if out := stdout.String(); out != "" {
|
||||
t.Fatalf("expected empty stdout on error, got: %s", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SubscribeTaskEvent = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+subscribe-event",
|
||||
Description: "subscribe to task events",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/task_v2/task_subscription").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
|
||||
// Parse and validate the envelope to avoid false-success output.
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
|
||||
}
|
||||
}
|
||||
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{"ok": true}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSubscribeTaskEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "execute json (user identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute json (bot identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute api error",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 401,
|
||||
"msg": "Unauthorized",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
wantParts: []string{"Unauthorized"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
mode: "dryrun",
|
||||
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
switch tt.mode {
|
||||
case "execute":
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
if tt.register != nil {
|
||||
tt.register(reg)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
out := err.Error()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("error missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
case "dryrun":
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
|
||||
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tasklistSearchDefaultPageLimit = 20
|
||||
tasklistSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-search",
|
||||
Description: "search tasklists",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:tasklist:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTasklistSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = tasklistSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > tasklistSearchMaxPageLimit {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasklists")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
tasklistID, _ := itemMap["id"].(string)
|
||||
if tasklistID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tasklist, err := getTasklistDetail(runtime, tasklistID)
|
||||
if err != nil {
|
||||
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklistID,
|
||||
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
urlVal, _ := tasklist["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklist["guid"],
|
||||
"name": tasklist["name"],
|
||||
"url": urlVal,
|
||||
"creator": tasklist["creator"],
|
||||
})
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": tasklists,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
return
|
||||
}
|
||||
for i, tasklist := range tasklists {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
|
||||
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["user_id"] = ids
|
||||
}
|
||||
if createTime := runtime.Str("create-time"); createTime != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(createTime)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
|
||||
}
|
||||
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
|
||||
mergeIntoFilter(filter, timeFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
if tasklist == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
|
||||
}
|
||||
return tasklist, nil
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "creator create-time and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("creator", "ou_creator")
|
||||
_ = cmd.Flags().Set("create-time", "-7d,+0d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := createTime["start_time"].(string)
|
||||
endTime, _ := createTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected create_time: %#v", createTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTasklistSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("create-time", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTasklist.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-fallback"`},
|
||||
},
|
||||
{
|
||||
name: "pretty fallback avoids nil name",
|
||||
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasklists found."},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ type WbCliOutput struct {
|
||||
}
|
||||
|
||||
type WbCliOutputData struct {
|
||||
To string `json:"to"`
|
||||
Result struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"result"`
|
||||
To string `json:"to"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -30,8 +31,9 @@ var formatCodeMap = map[string]int{
|
||||
FormatMermaid: 2,
|
||||
}
|
||||
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"}
|
||||
var wbUpdateAuthTypes = []string{"user", "bot"}
|
||||
var skipDeleteNodesBatchSleep = false // for accelerate UT testing only
|
||||
var wbUpdateFlags = []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
@@ -80,6 +82,19 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to update content."
|
||||
var delNum int
|
||||
var err error
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf(" %d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
|
||||
switch format {
|
||||
@@ -88,11 +103,7 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
reqBody := rawNodesCreateReq{
|
||||
Nodes: nodes,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(nodes).Desc("create all nodes of the whiteboard.")
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
@@ -100,11 +111,16 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
|
||||
}
|
||||
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
@@ -169,17 +185,31 @@ type createResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type deleteResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type simpleNodeResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
Id string `json:"id"`
|
||||
Children []string `json:"children"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type deleteNodeReqBody struct {
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type plantumlCreateReq struct {
|
||||
PlantUmlCode string `json:"plant_uml_code"`
|
||||
SyntaxType int `json:"syntax_type"`
|
||||
DiagramType int `json:"diagram_type,omitempty"`
|
||||
ParseMode int `json:"parse_mode,omitempty"`
|
||||
Overwrite bool `json:"overwrite,omitempty"`
|
||||
}
|
||||
|
||||
type rawNodesCreateReq struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
Overwrite bool `json:"overwrite,omitempty"`
|
||||
}
|
||||
|
||||
type plantumlCreateResp struct {
|
||||
@@ -190,7 +220,7 @@ type plantumlCreateResp struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool) {
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
|
||||
@@ -199,17 +229,121 @@ func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bo
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
if wbOutput.RawNodes != nil {
|
||||
wbNodes = wbOutput.RawNodes
|
||||
wbNodes = struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
}{
|
||||
Nodes: wbOutput.RawNodes,
|
||||
}
|
||||
isRaw = true
|
||||
} else {
|
||||
if wbOutput.Data.Result.Nodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
wbNodes = wbOutput.Data.Result.Nodes
|
||||
wbNodes = wbOutput.Data.Result
|
||||
}
|
||||
return wbNodes, nil, isRaw
|
||||
}
|
||||
|
||||
func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext, wbToken string, newNodeIDs []string, dryRun bool) (int, []string, error) {
|
||||
resp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var nodes simpleNodeResp
|
||||
err = json.Unmarshal(resp.RawBody, &nodes)
|
||||
if err != nil {
|
||||
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if nodes.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
|
||||
// 收集所有新节点及其 children 的 ID,递归处理
|
||||
protectedIDs := make(map[string]bool)
|
||||
for _, id := range newNodeIDs {
|
||||
protectedIDs[id] = true
|
||||
}
|
||||
// 构建 node map 以便快速查找
|
||||
nodeMap := make(map[string][]string)
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeMap[node.Id] = node.Children
|
||||
}
|
||||
}
|
||||
// 递归收集所有 children
|
||||
visited := make(map[string]bool)
|
||||
var collectChildren func(id string)
|
||||
collectChildren = func(id string) {
|
||||
if visited[id] {
|
||||
return
|
||||
}
|
||||
visited[id] = true
|
||||
if children, ok := nodeMap[id]; ok {
|
||||
for _, child := range children {
|
||||
protectedIDs[child] = true
|
||||
collectChildren(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range newNodeIDs {
|
||||
collectChildren(id)
|
||||
}
|
||||
|
||||
// 确定要删除的节点
|
||||
nodeIds := make([]string, 0, len(nodes.Data.Nodes))
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeIds = append(nodeIds, node.Id)
|
||||
}
|
||||
}
|
||||
delIds := make([]string, 0, len(nodeIds))
|
||||
for _, nodeId := range nodeIds {
|
||||
if !protectedIDs[nodeId] {
|
||||
delIds = append(delIds, nodeId)
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
// 实际删除节点,按每批最多100个进行切分
|
||||
for i := 0; i < len(delIds); i += 100 {
|
||||
if !skipDeleteNodesBatchSleep {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
}
|
||||
end := i + 100
|
||||
if end > len(delIds) {
|
||||
end = len(delIds)
|
||||
}
|
||||
batchIds := delIds[i:end]
|
||||
delReq := deleteNodeReqBody{
|
||||
Ids: batchIds,
|
||||
}
|
||||
resp, err = runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodDelete,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(wbToken)),
|
||||
Body: delReq,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, output.ErrNetwork(fmt.Sprintf("delete whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var delResp deleteResponse
|
||||
err = json.Unmarshal(resp.RawBody, &delResp)
|
||||
if err != nil {
|
||||
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard delete response failed: %v", err))
|
||||
}
|
||||
if delResp.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(delResp.Code, "delete whiteboard nodes failed", fmt.Sprintf("delete whiteboard nodes failed: %s", delResp.Msg))
|
||||
}
|
||||
}
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
|
||||
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
|
||||
syntaxType := formatCodeMap[format]
|
||||
@@ -218,7 +352,6 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0, // 0 表示自动识别
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
@@ -250,7 +383,20 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
|
||||
|
||||
outData := make(map[string]string)
|
||||
outData["created_node_id"] = createResp.Data.NodeID
|
||||
newNodeIDs := []string{createResp.Data.NodeID}
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, newNodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_id"] != "" {
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
}
|
||||
@@ -267,15 +413,11 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
|
||||
return err
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
reqBody := rawNodesCreateReq{
|
||||
Nodes: nodes,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
Body: nodes,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
@@ -310,7 +452,19 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
|
||||
}
|
||||
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
|
||||
@@ -478,9 +478,36 @@ invalid
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock: Create nodes API response with overwrite in request body
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{},
|
||||
},
|
||||
{
|
||||
"id": "old-node-2",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/plantuml",
|
||||
@@ -493,6 +520,16 @@ func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-overwrite", "--input_format", "mermaid", "--overwrite", "--source", source}
|
||||
@@ -502,9 +539,36 @@ A-->B`
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock: Create nodes API response with overwrite in request body
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{"old-child-1"},
|
||||
},
|
||||
{
|
||||
"id": "old-child-1",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
@@ -517,6 +581,16 @@ func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-overwrite", "--input_format", "raw", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
|
||||
@@ -58,11 +58,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As())
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
|
||||
}
|
||||
return dry
|
||||
return buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiNodeCreateSpec(runtime)
|
||||
@@ -74,7 +70,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy)
|
||||
runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil)
|
||||
runtime.Out(wikiNodeCreateOutput(execution), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -297,9 +293,6 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
|
||||
}
|
||||
|
||||
return &wikiNodeCreateExecution{
|
||||
Node: node,
|
||||
@@ -469,15 +462,3 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa
|
||||
"has_child": node.HasChild,
|
||||
}
|
||||
}
|
||||
|
||||
func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} {
|
||||
if execution == nil || execution.Node == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
out := wikiNodeCreateOutput(execution)
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type fakeWikiNodeCreateClient struct {
|
||||
spaces map[string]*wikiSpaceRecord
|
||||
nodes map[string]*wikiNodeRecord
|
||||
createNode *wikiNodeRecord
|
||||
returnNilNode bool
|
||||
createErr error
|
||||
getSpaceErr error
|
||||
getNodeErr error
|
||||
@@ -66,9 +65,6 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st
|
||||
if fake.createErr != nil {
|
||||
return nil, fake.createErr
|
||||
}
|
||||
if fake.returnNilNode {
|
||||
return nil, nil
|
||||
}
|
||||
if fake.createNode != nil {
|
||||
return fake.createNode, nil
|
||||
}
|
||||
@@ -85,15 +81,6 @@ func wikiTestConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func wikiPermissionTestConfig(userOpenID string) *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: fmt.Sprintf("wiki-permission-test-app-%d", wikiTestConfigSeq.Add(1)),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
}
|
||||
|
||||
func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "wiki"}
|
||||
@@ -281,26 +268,6 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
wikiMyLibrarySpaceID: {SpaceID: "space_my_library", SpaceType: "my_library"},
|
||||
},
|
||||
returnNilNode: true,
|
||||
}
|
||||
|
||||
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, wikiNodeCreateSpec{
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
ObjType: "docx",
|
||||
Title: "Roadmap",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") {
|
||||
t.Fatalf("expected missing node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateDryRunShowsMyLibraryLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -517,126 +484,3 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want completed creation message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_created",
|
||||
"obj_token": "docx_created",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/wik_created/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Node",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal permission body: %v", err)
|
||||
}
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
if body["perm_type"] != "container" {
|
||||
t.Fatalf("perm_type = %#v, want %q", body["perm_type"], "container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_created",
|
||||
"obj_token": "docx_created",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Node",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got)
|
||||
}
|
||||
|
||||
if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
- 用户要把本地 `.xlsx` / `.csv` 导入成 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`。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
|
||||
@@ -60,48 +60,6 @@ lark-cli mail user_mailbox.messages -h
|
||||
|
||||
`-h` 输出即可用 flag 的权威来源。reference 文档中的参数表可辅助理解语义,但实际 flag 名称以 `-h` 为准。
|
||||
|
||||
### 收件人搜索:查找邮箱地址
|
||||
|
||||
当需要查找收件人邮箱地址时,使用联系人搜索接口。支持多种搜索方式,如:
|
||||
- **按人名搜索**:如"给张三发邮件" → query="张三"
|
||||
- **按邮箱关键词搜索**:如"发到 larkmail 的邮箱" → query="@larkmail"
|
||||
- **按群名搜索**:如"发给项目群" → query="项目群"
|
||||
|
||||
```bash
|
||||
lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
```
|
||||
|
||||
搜索结果包含多种实体类型:
|
||||
|
||||
| `type` 值 | `tag` 示例 | 说明 |
|
||||
|-----------|-----------|------|
|
||||
| `user` / `chatter` | `chatter` | 个人用户 |
|
||||
| `enterprise_mail_group` | `mail_group` | 企业邮件组 |
|
||||
| `chat` / `group` | `chat_group_tenant` / `chat_group_normal` | 群聊(有群邮件地址) |
|
||||
| `external_contact` | `external_contact` | 外部联系人 |
|
||||
|
||||
**处理规则:**
|
||||
1. 从结果中筛选有 `email` 字段的条目
|
||||
2. 无论匹配数量多少,都必须列出候选项供用户确认后再使用(搜索是模糊匹配,单条结果不代表精确命中)。展示尽可能多的字段帮助用户区分:
|
||||
```text
|
||||
找到以下匹配"张三"的结果:
|
||||
1. 张三 <zhangsan@example.com>
|
||||
类型:user | 部门:研发团队
|
||||
---
|
||||
找到多个匹配"组"的结果,请选择:
|
||||
1. 团队邮件组 <team@example.com>
|
||||
类型:enterprise_mail_group | 标签:mail_group
|
||||
2. 项目群 <project@example.com>
|
||||
类型:chat | 成员数:50 | 标签:chat_group_normal
|
||||
3. 张群 <zhangqun@example.com>
|
||||
类型:user | 部门:研发团队 | 备注名:张群同学
|
||||
```
|
||||
可用字段:`name`(名称)、`email`(邮箱)、`department`(部门)、`tag`(标签)、`display_name`(备注名)、`type`(实体类型)、`member_count`(成员数,群类型时展示)。字段为空时省略。
|
||||
3. 若无匹配,告知用户未找到,建议换关键词或直接提供邮箱地址
|
||||
4. 用户确认后,将 `email` 传入 compose shortcut 的 `--to` / `--cc` / `--bcc` 参数
|
||||
|
||||
**注意:** 用户直接提供完整邮箱地址时不需要搜索,直接使用即可。
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
@@ -159,30 +117,6 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
|
||||
**撤回操作:**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.sent_messages recall --as user \
|
||||
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
|
||||
```
|
||||
|
||||
- 返回 `recall_status: available` 表示撤回请求已受理(异步执行)
|
||||
- 返回 `recall_status: unavailable` 表示不可撤回,`recall_restriction_reason` 说明原因
|
||||
|
||||
**查询撤回进度:**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
|
||||
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
|
||||
```
|
||||
|
||||
- `recall_status: in_progress` — 撤回进行中,可稍后再查
|
||||
- `recall_status: done` — 撤回完成,查看 `recall_result`(`all_success` / `all_fail` / `some_fail`)和每个收件人的详情
|
||||
|
||||
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
|
||||
|
||||
### 正文格式:优先使用 HTML
|
||||
|
||||
撰写邮件正文时,**默认使用 HTML 格式**(body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。
|
||||
|
||||
@@ -157,9 +157,7 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- 公式不支持跨表引用(IMPORTRANGE)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
|
||||
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user