Compare commits

..

12 Commits

Author SHA1 Message Date
zhanghuanxu
0b0864d8e5 feat: add slide text wrap lint 2026-06-25 18:00:34 +08:00
zhanghuanxu
c43b231200 feat: add slides xml get shortcut 2026-06-25 17:59:58 +08:00
zhanghuanxu
6f79b98fc3 feat: add slides replace-pages shortcut 2026-06-25 17:59:57 +08:00
zhanghuanxu
50d5b38b18 docs: refine lark-slides imported material workflow 2026-06-23 20:20:52 +08:00
zhanghuanxu
2c302810f7 docs: clarify pdf slides import polling 2026-06-23 17:42:27 +08:00
zhanghuanxu
808e1e3787 docs: refine lark slides planning guidance 2026-06-22 19:00:13 +08:00
zhanghuanxu
dec18bc9fa feat: support slides XML page selectors 2026-06-22 14:27:22 +08:00
zhanghuanxu
9d53770590 feat: support PDF imports as slides 2026-06-22 14:27:20 +08:00
zhanghuanxu
6fec103ac9 revert:design ideas 2026-06-22 14:26:06 +08:00
zhanghuanxu
bc470c8c17 docs: improve slides attachment planning 2026-06-22 14:26:06 +08:00
zhanghuanxu
a48dc3cef8 docs: update lark slides planning guidance 2026-06-22 14:26:06 +08:00
zhanghuanxu
ff23931da1 feat(slides):improve slides attachment planning 2026-06-22 14:26:05 +08:00
169 changed files with 4577 additions and 12532 deletions

View File

@@ -318,39 +318,7 @@ jobs:
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
# --ignore github.com/apache/arrow/go/v17: Arrow is Apache-2.0 overall,
# but its LICENSE.txt also inlines the c-ares 3rdparty notice (Arrow's
# python wheels statically link c-ares) — and go-licenses' classifier
# parses the whole file as a single license, so it reports the module
# as "LicenseRef-C-Ares / Unknown". The follow-up step pins the actual
# license type by inspecting the LICENSE.txt itself, so the wholesale
# --ignore here doesn't become a free pass for future Arrow re-licensing.
# Required by sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC ingest).
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown --ignore github.com/apache/arrow/go/v17
- name: Assert Apache Arrow LICENSE.txt remains Apache-2.0
# Independent re-check that the go-licenses ignore above is purely a
# classifier workaround, not a free pass: confirm Arrow's LICENSE.txt
# still opens with the Apache License and still inlines the c-ares
# notice that is the actual reason go-licenses misreports the module.
# If Arrow ever re-licenses its primary license or drops the c-ares
# notice (meaning go-licenses might start reporting a different /
# genuinely problematic identifier instead), this step fails and a
# human must re-evaluate the --ignore.
run: |
set -euo pipefail
LICENSE_PATH="$(go env GOMODCACHE)/github.com/apache/arrow/go/v17@v17.0.0/LICENSE.txt"
if [ ! -f "$LICENSE_PATH" ]; then
echo "::error::Apache Arrow LICENSE.txt not found at $LICENSE_PATH" >&2
exit 1
fi
if ! head -50 "$LICENSE_PATH" | grep -q 'Apache License' ; then
echo "::error::Apache Arrow LICENSE.txt no longer leads with the Apache License — re-evaluate the go-licenses --ignore" >&2
exit 1
fi
if ! grep -q '3rdparty dependency c-ares' "$LICENSE_PATH" ; then
echo "::error::Apache Arrow LICENSE.txt no longer inlines the c-ares notice — go-licenses may now report a different identifier; re-evaluate the --ignore" >&2
exit 1
fi
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' }}

View File

@@ -47,13 +47,10 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -74,11 +71,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -126,7 +123,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
@@ -258,13 +255,10 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -285,11 +279,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -337,7 +331,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");

5
.gitignore vendored
View File

@@ -7,11 +7,6 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store

View File

@@ -2,30 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1236,7 +1212,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

View File

@@ -347,6 +347,89 @@ func boolIntQueryMethod(required bool) meta.Method {
})
}
func slidesSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "slides",
"servicePath": "/open-apis/slides_ai/v1",
})
}
func slidesXMLPresentationSlideGetMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "xml_presentations/{xml_presentation_id}/slide",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"slide_id": map[string]interface{}{"type": "string", "location": "query"},
"slide_number": map[string]interface{}{"type": "integer", "location": "query"},
},
})
}
func slidesXMLPresentationsGetMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "xml_presentations/{xml_presentation_id}",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"revision_id": map[string]interface{}{"type": "integer", "location": "query"},
"remove_attr_id": map[string]interface{}{"type": "boolean", "location": "query"},
},
})
}
func TestSlidesXMLPresentationsGet_RemoveAttrID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationsGetMethod(), "get", "xml_presentations", nil)
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--remove-attr-id", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123", `"remove_attr_id": true`} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
}
func TestSlidesXMLPresentationSlideGet_BySlideNumber(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-number", "2", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123/slide", `"slide_number": 2`} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
if strings.Contains(out, "slide_id") {
t.Errorf("slide_number-only request should not include slide_id, got:\n%s", out)
}
}
func TestSlidesXMLPresentationSlideGet_SlideIDWinsOverSlideNumber(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-id", "sld_123", "--slide-number", "2", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"slide_id": "sld_123"`) {
t.Errorf("expected slide_id in dry-run output, got:\n%s", out)
}
if strings.Contains(out, "slide_number") {
t.Errorf("slide_id should take precedence over slide_number, got:\n%s", out)
}
}
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
// so --flag=false / --flag 0 are real values and must be sent — not silently
// dropped as "empty", which would let the API default win over an explicit

View File

@@ -566,6 +566,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
for name, value := range params {
queryParams[name] = value
}
normalizeServiceQueryParams(opts.SchemaPath, queryParams)
request := client.RawApiRequest{
Method: httpMethod,
@@ -623,6 +624,15 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func normalizeServiceQueryParams(schemaPath string, queryParams map[string]interface{}) {
if schemaPath != "slides.xml_presentation.slide.get" {
return
}
if slideID, ok := queryParams["slide_id"]; ok && !unusableParamValue(slideID) {
delete(queryParams, "slide_number")
}
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}

12
go.mod
View File

@@ -27,8 +27,6 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -44,17 +42,13 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -63,16 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -54,16 +52,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -80,16 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -108,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -146,20 +133,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -175,7 +156,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -189,16 +169,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -75,6 +75,8 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_pdf2slide")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}

View File

@@ -30,10 +30,10 @@ const OAuthTokenV3Path = "/oauth/v3/token"
// Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"
Accounts string // e.g. "https://accounts.feishu.cn"
Open string // e.g. "https://open.feishu-pre.cn"
Accounts string // e.g. "https://accounts.feishu-pre.cn"
MCP string // e.g. "https://mcp.feishu.cn"
AppLink string // e.g. "https://applink.feishu.cn"
AppLink string // e.g. "https://applink.feishu-pre.cn"
}
// ResolveEndpoints resolves endpoint URLs based on brand.
@@ -48,10 +48,10 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
}
default:
return Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
Open: "https://open.feishu-pre.cn",
Accounts: "https://accounts.feishu-pre.cn",
MCP: "https://mcp.feishu.cn",
AppLink: "https://applink.feishu.cn",
AppLink: "https://applink.feishu-pre.cn",
}
}
}

View File

@@ -113,8 +113,7 @@ type EnumOption struct {
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum (with descriptions backfilled from options when the field carries
// both forms), or from options when enum is absent — coerced to the canonical
// — from enum, or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
@@ -123,14 +122,9 @@ func (f Field) EnumOptions() []EnumOption {
var out []EnumOption
switch {
case len(f.Enum) > 0:
// key by raw literal so enum "1" and option 1 align across JSON types
desc := make(map[string]string, len(f.Options))
for _, o := range f.Options {
desc[fmt.Sprintf("%v", o.Value)] = o.Description
}
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
out = append(out, EnumOption{Value: v})
}
}
case len(f.Options) > 0:

View File

@@ -80,39 +80,6 @@ func TestField_EnumOptions(t *testing.T) {
}
}
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
// enum is the value set; descriptions backfilled from options, empty where absent
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "6", Description: "subject"},
}}
want := []EnumOption{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "3", Description: ""},
{Value: "4", Description: ""},
{Value: "6", Description: "subject"},
}
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
}
// enum values stored as strings match option values stored as numbers
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
{Value: 1, Description: "one"},
{Value: 2, Description: "two"},
}}
wantI := []EnumOption{
{Value: int64(1), Description: "one"},
{Value: int64(2), Description: "two"},
{Value: int64(10), Description: ""},
}
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
}
}
func TestField_Enum_NumberAndBoolean(t *testing.T) {
// number: string-stored floats coerced to float64 and numerically sorted
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {

View File

@@ -472,18 +472,6 @@ func TestConvert_EnumDescriptions(t *testing.T) {
if bare.EnumDescriptions != nil {
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
}
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
}})
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
t.Errorf("both Enum = %v", both.Enum)
}
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
}
}
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.57",
"version": "1.0.56",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -179,10 +179,7 @@ fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -201,9 +198,8 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
@@ -214,8 +210,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -85,9 +84,6 @@ var AppsHTMLPublish = common.Shortcut{
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
dry.Set("oversize_html", hits)
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
@@ -144,22 +140,18 @@ type appsHTMLPublishSpec struct {
// per-environment .env.* files for every stage).
const maxSensitiveListInError = 5
// truncatedJoin joins items with ", ", capping at max entries and appending
// "(and N more)" for the remainder, so an inline error list stays readable when
// a payload has many hits.
func truncatedJoin(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
}
// sensitiveCandidatesError builds the Validate-time rejection when --path
// contains credential files and --allow-sensitive was not set.
func sensitiveCandidatesError(hits []string) error {
var sample string
if len(hits) <= maxSensitiveListInError {
sample = strings.Join(hits, ", ")
} else {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s",
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
@@ -176,30 +168,6 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html大小写不敏感且单个 Size 超过
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
var hits []string
for _, c := range candidates {
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
hits = append(hits, c.RelPath)
}
}
return hits
}
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
func oversizeHTMLFilesError(hits []string) error {
return appsValidationParamError("--path",
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
}
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
@@ -222,9 +190,6 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
return nil, oversizeHTMLFilesError(hits)
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size

View File

@@ -503,82 +503,3 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
cands := []htmlPublishCandidate{
{RelPath: "index.html", Size: 50},
{RelPath: "big.html", Size: 4096},
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
{RelPath: "huge.png", Size: 9000}, // 非 .html忽略
}
hits := oversizeHTMLFiles(cands)
if len(hits) != 2 {
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
}
for _, h := range hits {
if h == "huge.png" || h == "index.html" {
t.Fatalf("unexpected hit %q", h)
}
}
}
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
}
}
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -99,14 +99,7 @@ var AppsInit = common.Shortcut{
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
if existing != "" {
dry.Set("app_id_mismatch", existing)
}
dry.Set("dir_error", e.Error())
} else {
dry.Set("already_initialized", true)
}
dry.Set("already_initialized", true)
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
@@ -206,61 +199,6 @@ func isAlreadyInitialized(dir string) bool {
return err == nil && !info.IsDir()
}
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id用于判断目标目录是否同一个妙搭应用。
// 返回 (appID, isSparkProject, err)
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
// - 解析成功 → (trim 后的 app_id, true, nil)app_id 缺失/为空时为 ""
func readMetaAppID(dir string) (string, bool, error) {
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m struct {
AppID string `json:"app_id"`
}
if err := json.Unmarshal(b, &m); err != nil {
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
return strings.TrimSpace(m.AppID), true, nil
}
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
// - 不是妙搭工程(无 meta.json → nil交给 ensureEmptyDir 判空/非空)
// - 是妙搭工程且 app_id 与 appID 一致 → nil走已初始化短路复用本地代码
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
// - meta.json 损坏/不可读,无法确认 → 报错fail closed提示换目录
//
// 返回值 existing 是目录里已存在的 app_id仅"已是另一个 app"的拒绝场景非空),供调用方在
// dry-run 里回填 app_id_mismatch避免二次读 meta.json。
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
existing, isSpark, readErr := readMetaAppID(dir)
if readErr != nil {
return "", appsValidationParamError("--dir",
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
WithCause(readErr)
}
if !isSpark || existing == appID {
return existing, nil
}
if existing == "" {
// meta 存在但缺 app_id更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
return "", appsValidationParamError("--dir",
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("remove the directory and re-run +init, or choose a different --dir")
}
return existing, appsValidationParamError("--dir",
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
dir, existing, appID).
WithHint("choose a different --dir (or cd into the matching project) before running +init")
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
@@ -440,11 +378,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.

View File

@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
@@ -394,40 +394,6 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
}
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
dir := relCloneDir(t)
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("mismatched app_id must error")
}
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--dir" {
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
}
if !strings.Contains(problem.Message, "different app") {
t.Fatalf("message=%q, want 'different app'", problem.Message)
}
for _, c := range f.calls {
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
}
}
}
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_x.git"),
@@ -1502,125 +1468,6 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
}
}
func TestReadMetaAppID(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 不存在 meta.json → ("", false, nil)
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
}
// 存在且有 app_id → (app_id, true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
}
// 存在但 app_id 空 → ("", true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
}
// 存在但坏 JSON → ("", false, err)(无法确认)
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
}
}
func TestEnsureInitDirMatchesApp(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 无 meta非妙搭工程→ nil交给 ensureEmptyDir
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
t.Fatalf("no meta should pass: %v", err)
}
// 同 app_id → (app_id, nil)(走已初始化短路)
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
}
// 不同 app_id → error换目录返回 existing=app_other断言 typed metadatasubtype/param
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
if errMismatch == nil {
t.Fatal("different app should error")
}
if existing != "app_other" {
t.Fatalf("mismatch should return existing app_id, got %q", existing)
}
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(errMismatch, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
}
if ve.Param != "--dir" {
t.Fatalf("param=%q, want --dir", ve.Param)
}
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
}
if !strings.Contains(problem.Hint, "different --dir") {
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
}
// 空 app_id缺 app_id 标记的半成品)→ error独立文案非 "different app"),返回 existing=""
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
if errEmpty == nil {
t.Fatal("empty meta app_id should error (cannot confirm same app)")
}
if emptyExisting != "" {
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
}
pEmpty := requireAppsValidationProblem(t, errEmpty)
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pEmpty.Message, "without an app_id") {
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
}
if strings.Contains(pEmpty.Message, "different app") {
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
}
// meta 损坏/不可读 → errorfail closed返回 existing=""
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
if errBad == nil {
t.Fatal("corrupted meta should fail closed")
}
if badExisting != "" {
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
}
pBad := requireAppsValidationProblem(t, errBad)
if pBad.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
}
var veBad *errs.ValidationError
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.

View File

@@ -17,7 +17,6 @@ import (
"text/tabwriter"
"time"
"github.com/google/uuid"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
@@ -33,7 +32,6 @@ import (
)
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
@@ -304,12 +302,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
}
ctx = contextWithGitCredentialHelperShortcut(ctx)
var opts []larkcore.RequestOptionFunc
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
opts = append(opts, optFn)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
@@ -321,13 +314,6 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
return issuedFromData(appID, data)
}
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
return ctx
}
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
}
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
if f == nil || f.IOStreams == nil {
return nil

View File

@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
func TestFactoryIssuerBranches(t *testing.T) {
factory, _, reg := newAppsExecuteFactory(t)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
issueStub := &httpmock.Stub{
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
@@ -836,8 +836,7 @@ func TestFactoryIssuerBranches(t *testing.T) {
"StatusCode": 0,
},
},
}
reg.Register(issueStub)
})
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
if err != nil {
t.Fatalf("factory issuer returned error: %v", err)
@@ -845,12 +844,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
if issued.PAT != "pat-token" {
t.Fatalf("PAT = %q", issued.PAT)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
}
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
@@ -887,20 +880,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
}
}
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
got := contextWithGitCredentialHelperShortcut(ctx)
name, ok := cmdutil.ShortcutNameFromContext(got)
if !ok || name != "apps:+git-credential-init" {
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
}
executionID, ok := cmdutil.ExecutionIdFromContext(got)
if !ok || executionID != "exec-existing" {
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
}
}
func TestGitCredentialHelpersAndParsers(t *testing.T) {
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))

View File

@@ -199,7 +199,16 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
})
}
}

View File

@@ -142,7 +142,9 @@ func TestNormalizeMCPToolResult(t *testing.T) {
got, err := normalizeMCPToolResult(tt.raw)
if tt.wantErr != "" {
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
return
}
if err != nil {

View File

@@ -49,21 +49,8 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when any flag has consumed stdin (`-`); used so out-of-band binary readers (e.g. sheets +table-put --dataframe) can refuse a second stdin consumer instead of racing for an already-empty stream
}
// StdinConsumed reports whether stdin has already been consumed by an Input
// flag's `-` form via resolveInputFlags. Out-of-band binary readers that read
// stdin themselves (currently sheets +table-put / +workbook-create --dataframe)
// must check this before reading — a process has a single stdin, so two
// consumers would race and one would see an empty stream.
func (ctx *RuntimeContext) StdinConsumed() bool { return ctx.stdinConsumed }
// MarkStdinConsumed marks stdin as consumed. Out-of-band binary readers must
// call this after they read stdin so a later Input-flag `-` is rejected cleanly
// instead of racing on an empty stream.
func (ctx *RuntimeContext) MarkStdinConsumed() { ctx.stdinConsumed = true }
// ── Identity ──
// As returns the current identity.
@@ -1042,6 +1029,7 @@ func stripUTF8BOM(s string) string {
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
@@ -1061,15 +1049,11 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
// stdinConsumed also covers out-of-band readers like sheets +table-put
// --dataframe (binary, doesn't go through Input). A process has a
// single stdin, so we reject a second consumer regardless of source.
if rctx.stdinConsumed {
if stdinUsed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
WithParam("--" + fl.Name)
}
rctx.stdinConsumed = true
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
@@ -1182,13 +1166,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
hints = append(hints, "@file")
}
if slices.Contains(fl.Input, Stdin) {
// "- reads stdin" intentionally avoids implying each flag has
// its own stdin: a process has a single stdin, so at most one
// flag per call may use "-" (the rest must use @file). The old
// per-flag "- for stdin" wording led AI agents to write
// `--a - <x --b - <y`, where the second `<` silently clobbers
// the first and `--a` reads the wrong payload.
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
hints = append(hints, "- for stdin")
}
desc += " (supports " + strings.Join(hints, ", ") + ")"
}

View File

@@ -22,7 +22,6 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for positional arg, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("expected positional args rejection message, got: %v", err)
}
@@ -40,7 +39,6 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for multiple positional args, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("unexpected error message: %v", err)
}

View File

@@ -171,7 +171,6 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-zero code")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "[99991]") {
t.Errorf("error = %q, want substring [99991]", err.Error())
}
@@ -198,7 +197,6 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty open_id")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "open_id is empty") {
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
}
@@ -220,7 +218,6 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
if err == nil {
t.Fatal("expected error for HTTP 403")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, want substring '403'", err.Error())
}
@@ -241,7 +238,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
// Error may come from SDK-level parse or our unmarshal wrapper
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
t.Errorf("error = %q, want JSON parse failure", err.Error())
}
@@ -282,7 +279,6 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
if info != nil {
t.Errorf("expected nil info, got %+v", info)
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, want substring 'not available'", err.Error())
}
@@ -295,7 +291,6 @@ func TestBotInfo_NilFunc(t *testing.T) {
if err == nil {
t.Fatal("expected error for nil botInfoFunc")
}
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not fully initialized") {
t.Errorf("unexpected error: %v", err)
}

View File

@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support stdin") {
t.Errorf("unexpected error message: %q", vErr.Message)
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support file input") {
t.Errorf("unexpected error message: %q", vErr.Message)
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "cannot read file") {
t.Errorf("unexpected error message: %q", vErr.Message)
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
t.Errorf("unexpected error message: %q", vErr.Message)
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -216,14 +216,9 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
vErr := assertValidationParam(t, err, "--b")
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
// The hint must steer an AI agent to the fix (@file for the extra flags),
// since `--a - <x --b - <y` is the exact misuse this guards against.
if !strings.Contains(vErr.Hint, "@file") {
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -186,7 +186,9 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
requireValidation(t, err, "mutually exclusive")
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
@@ -206,7 +208,9 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
requireValidation(t, err, "invalid jq expression")
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
}
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}

View File

@@ -85,7 +85,6 @@ type searchUserAPIData struct {
Items []searchUserAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type searchUserAPIItem struct {
@@ -127,7 +126,6 @@ type searchUser struct {
type searchUserResponse struct {
Users []searchUser `json:"users"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchUser = common.Shortcut{
@@ -191,7 +189,6 @@ var ContactSearchUser = common.Shortcut{
Execute: executeSearchUser,
}
// executeSearchUser dispatches contact search to single-query or fanout mode.
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
@@ -199,7 +196,6 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
return executeSearchUserSingle(ctx, runtime)
}
// executeSearchUserSingle performs one contact search and preserves server notices.
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
@@ -226,7 +222,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
out := searchUserResponse{Users: users, HasMore: hasMore}
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
if len(users) == 0 {

View File

@@ -45,17 +45,22 @@ type fanoutResult struct {
Query string
Users []searchUser
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed all-failed propagation
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts one fanout request into either users or an error summary.
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
@@ -89,10 +94,9 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
}
// fanoutErrorResult records a failed fanout query without stopping other workers.
func fanoutErrorResult(index int, query string, err error) fanoutResult {
if err == nil {
return fanoutResult{Index: index, Query: query}
@@ -109,16 +113,17 @@ type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
@@ -137,7 +142,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
@@ -148,9 +152,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}

View File

@@ -562,7 +562,6 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
return parent.Execute()
}
// searchUserStub returns a representative user search response with a notice.
func searchUserStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
@@ -570,7 +569,6 @@ func searchUserStub() *httpmock.Stub {
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
@@ -592,7 +590,6 @@ func searchUserStub() *httpmock.Stub {
}
}
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -617,7 +614,6 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
}
}
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -635,9 +631,6 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
if !ok {
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
@@ -1365,7 +1358,6 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
}
}
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
@@ -1407,7 +1399,6 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
}
}
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -1415,7 +1406,6 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
@@ -1442,17 +1432,10 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q0 := queries[0].(map[string]interface{})
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("queries[0].notice = %v", q0["notice"])
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])

View File

@@ -74,9 +74,6 @@ var DocsSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
if len(normalizedItems) == 0 {

View File

@@ -7,48 +7,8 @@ import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DocsSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
t.Parallel()

View File

@@ -121,7 +121,7 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
@@ -148,17 +148,6 @@ var DriveAddComment = common.Shortcut{
return err
}
if docRef.Kind == "base" {
if runtime.Bool("full-comment") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
}
_, err := parseBaseCommentAnchor(runtime)
return err
}
// Sheet comment validation.
if docRef.Kind == "sheet" {
blockID := strings.TrimSpace(runtime.Str("block-id"))
@@ -199,7 +188,7 @@ var DriveAddComment = common.Shortcut{
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
return nil
@@ -226,23 +215,6 @@ var DriveAddComment = common.Shortcut{
resolvedToken = target.FileToken
}
if resolvedKind == "base" {
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
desc := "1-step request: create base(bitable) record-local comment"
if isWiki {
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Sheet comment dry-run.
if resolvedKind == "sheet" {
anchor, _ := parseSheetCellRef(blockID)
@@ -380,14 +352,6 @@ var DriveAddComment = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Sheet comment: direct URL or token fast path.
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
if docRef.Kind == "base" {
return executeBaseComment(runtime, resolvedCommentTarget{
DocID: docRef.Token,
FileToken: docRef.Token,
FileType: "base",
ResolvedBy: "base",
})
}
if docRef.Kind == "sheet" {
return executeSheetComment(runtime, docRef)
}
@@ -411,9 +375,6 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "base" {
return executeBaseComment(runtime, target)
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
@@ -521,12 +482,6 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/base/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/bitable/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
@@ -540,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
@@ -549,10 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
}
if docType == "bitable" || docType == "base" {
return commentDocRef{Kind: "base", Token: raw}, nil
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -563,11 +515,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
@@ -605,22 +557,6 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
}
if objType == "bitable" || objType == "base" {
if runtime.Bool("full-comment") {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "base",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if objType == "sheet" {
// Sheet comments are handled via the sheet fast path in Execute.
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -656,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -851,12 +787,6 @@ type sheetAnchor struct {
Row int
}
type baseAnchor struct {
BlockID string
BaseRecordID string
BaseViewID string
}
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
body := map[string]interface{}{
"file_type": fileType,
@@ -883,18 +813,6 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
return body
}
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
return map[string]interface{}{
"file_type": "bitable",
"reply_elements": replyElements,
"anchor": map[string]interface{}{
"block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
},
}
}
func anchorBlockIDForDryRun(blockID string) string {
if strings.TrimSpace(blockID) != "" {
return strings.TrimSpace(blockID)
@@ -902,26 +820,6 @@ func anchorBlockIDForDryRun(blockID string) string {
return "<anchor_block_id>"
}
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
}
return parseBaseBlockRef(blockID)
}
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
parts := strings.Split(strings.TrimSpace(blockID), "!")
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
}
return baseAnchor{
BlockID: strings.TrimSpace(parts[0]),
BaseRecordID: strings.TrimSpace(parts[1]),
BaseViewID: strings.TrimSpace(parts[2]),
}, nil
}
func parseSlidesBlockRef(blockID string) (string, string, error) {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
@@ -1132,53 +1030,6 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": target.FileToken,
"file_type": "bitable",
"resolved_by": target.ResolvedBy,
"comment_mode": "base_record",
"base_block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
}
if commentID := data["comment_id"]; commentID != nil {
out["comment_id"] = commentID
}
if replyID := data["reply_id"]; replyID != nil {
out["reply_id"] = replyID
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -133,20 +133,6 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token with type bitable",
input: "baseToken",
docType: "bitable",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token with type base alias",
input: "baseToken",
docType: "base",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -170,18 +156,6 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "base url",
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
wantKind: "base",
wantToken: "baseToken123",
},
{
name: "bitable url",
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
wantKind: "base",
wantToken: "baseToken456",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -752,35 +726,6 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
}
}
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{"type": "text", "text": "base comment"},
}
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
BlockID: "tbl9mp6fj9kDKHQV",
BaseRecordID: "recBIBgGmb",
BaseViewID: "vewc46MG1R",
})
if got["file_type"] != "bitable" {
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
}
if anchor["base_record_id"] != "recBIBgGmb" {
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
}
if anchor["base_view_id"] != "vewc46MG1R" {
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
}
}
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
func TestParseSheetCellRef(t *testing.T) {
@@ -1040,78 +985,6 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
}
}
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
t.Fatalf("expected block-id required error, got: %v", err)
}
}
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
cases := []string{
"tbl9mp6fj9kDKHQV",
"tbl9mp6fj9kDKHQV!recBIBgGmb",
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
}
for _, blockID := range cases {
t.Run(blockID, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", blockID,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
t.Fatalf("expected block-id format error, got: %v", err)
}
})
}
}
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
},
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1322,87 +1195,6 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestBaseCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"comment_id": "baseComment123",
"reply_id": "baseReply123",
"created_at": 1700000000,
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"请看这条记录"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
}
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
t.Fatalf("request file_type = %q, want bitable", got)
}
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
}
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
}
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
}
}
func TestBaseCommentExecuteBareToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "baseBareComment"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "baseBareToken",
"--type", "bitable",
"--content", `[{"type":"text","text":"ok"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "baseBareComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1641,40 +1433,6 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
}
}
func TestDryRunBaseDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "record-local comment") {
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
}
}
func TestDryRunWikiResolvesToSlides(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1878,92 +1636,25 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
}
}
func TestResolveWikiToBaseComment(t *testing.T) {
for _, objType := range []string{"bitable", "base"} {
t.Run(objType, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "wikiBaseComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
}
})
}
}
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
func TestResolveWikiToUnsupportedType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
},
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
},
})
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", err)
}
}
@@ -2044,7 +1735,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
}
}

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -16,24 +15,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// wrapExportContextErr converts a context cancellation / deadline error into a
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
// preserved for errors.Is) instead of an untyped context.Canceled /
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
func wrapExportContextErr(err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
msg := "drive +export polling cancelled: %s"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
msg = "drive +export polling deadline exceeded: %s"
}
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
}
// DriveExport exports Drive-native documents to local files and falls back to
// a follow-up command when the async export task does not finish in time.
var DriveExport = common.Shortcut{
@@ -59,302 +40,236 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateExport(exportParamsFromFlags(runtime))
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OnlySchema bool
OutputDir string
FileName string
Overwrite bool
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
OnlySchema: p.OnlySchema,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
return err
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return wrapExportContextErr(ctx.Err())
case <-time.After(driveExportPollInterval):
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
}
if err := ctx.Err(); err != nil {
return wrapExportContextErr(err)
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
return err
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
}

View File

@@ -5,7 +5,6 @@ package drive
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
@@ -498,72 +497,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1101,37 +1034,3 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
}
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
// directly so an untyped context.Canceled would escape as a plain string at
// the command layer, bypassing the typed-error contract.
func TestWrapExportContextErr(t *testing.T) {
if err := wrapExportContextErr(nil); err != nil {
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
}
cancelled := wrapExportContextErr(context.Canceled)
var netErrCancel *errs.NetworkError
if !errors.As(cancelled, &netErrCancel) {
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
}
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(cancelled, context.Canceled) {
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
}
deadline := wrapExportContextErr(context.DeadlineExceeded)
var netErrDeadline *errs.NetworkError
if !errors.As(deadline, &netErrDeadline) {
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
}
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
}
if !errors.Is(deadline, context.DeadlineExceeded) {
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
}
}

View File

@@ -28,171 +28,139 @@ var DriveImport = common.Shortcut{
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateImport(importParamsFromFlags(runtime))
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
"csv": {"sheet", "bitable"},
"base": {"bitable"},
"pptx": {"slides"},
"pdf": {"slides"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "pptx":
case "pptx", "pdf":
return driveImport500MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
}
switch spec.DocType {
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
}
typeAllowed := false
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
case "pptx":
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
case "pptx", "pdf":
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}

View File

@@ -41,6 +41,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
name: "pptx slides ok",
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
},
{
name: "pdf slides ok",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
@@ -51,6 +55,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
wantErr: ".pptx files can only be imported as 'slides'",
},
{
name: "pdf non slides rejected",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
wantErr: ".pdf files can only be imported as 'slides'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
@@ -138,6 +147,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "pdf exceeds 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit + 1,
wantText: "exceeds 500.0 MB import limit for .pdf",
},
{
name: "pdf within 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",

View File

@@ -149,9 +149,6 @@ var DriveSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
renderDriveSearchTable(w, data, normalizedItems)

View File

@@ -14,49 +14,12 @@ import (
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DriveSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
// narrows --opened-since / --opened-until and generates the multi-slice notice.
func TestClampOpenedTimeWindow(t *testing.T) {
t.Parallel()

View File

@@ -26,7 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -57,38 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -258,7 +231,6 @@ func TestIsMediaKey(t *testing.T) {
}
}
// TestShortcutValidateBranches covers direct shortcut validation branches.
func TestShortcutValidateBranches(t *testing.T) {
t.Run("ImChatCreate valid", func(t *testing.T) {
@@ -325,7 +297,7 @@ func TestShortcutValidateBranches(t *testing.T) {
})
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
runtime := newTestRuntimeContext(t, map[string]string{
"query": "ok",
"page-size": "0",
}, nil)
@@ -335,13 +307,12 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 81),
"page-size": "20",
t.Run("ImChatSearch query too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 65),
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err != nil {
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
@@ -636,7 +607,6 @@ func TestShortcutValidateBranches(t *testing.T) {
})
}
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
@@ -680,7 +650,8 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
@@ -703,19 +674,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
})
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
t.Fatalf("ImChatSearch.DryRun() = %s", got)
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,

View File

@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
{Name: "query", Desc: "search keyword (max 64 chars)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
Params(params).
Body(body)
},
// Validate enforces query/member-ids presence, search-types
// Validate enforces query/member-ids presence, --query rune cap, search-types
// enum, --member-ids count and format, and --page-size bounds.
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
query := runtime.Str("query")
@@ -58,6 +58,9 @@ var ImChatSearch = common.Shortcut{
if query == "" && memberIDs == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
}
if query != "" && len([]rune(query)) > 64 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
}
if st := runtime.Str("search-types"); st != "" {
allowed := map[string]struct{}{
"private": {},
@@ -148,9 +151,6 @@ var ImChatSearch = common.Shortcut{
"has_more": hasMore,
"page_token": pageToken,
}
if notice, _ := resData["notice"].(string); notice != "" {
outData["notice"] = notice
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}

View File

@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
return err
}
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
if err != nil {
return err
}
@@ -103,9 +103,6 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "No matching messages found.")
})
@@ -134,9 +131,6 @@ var ImMessagesSearch = common.Shortcut{
"page_token": nextPageToken,
"note": "failed to fetch message details, returning ID list only",
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
for _, id := range messageIds {
@@ -212,9 +206,6 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No matching messages found.")
@@ -386,7 +377,6 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
}, nil
}
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
autoPaginate = runtime.Bool("page-all")
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
@@ -402,8 +392,7 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
return autoPaginate, pageLimit
}
// searchMessages fetches message search pages and returns the first server notice.
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
pageToken := ""
if tokens := req.params["page_token"]; len(tokens) > 0 {
@@ -421,7 +410,6 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
lastPageToken string
truncatedByLimit bool
pageCount int
notice string
)
for {
@@ -435,12 +423,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, "", err
return nil, false, "", false, pageLimit, err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
items, _ := searchData["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
@@ -456,10 +441,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
pageToken = lastPageToken
}
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
}
// batchMGetMessages fetches message details in API-sized batches.
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
@@ -473,7 +457,6 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
return items, nil
}
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
// Best-effort: a failed chunk only loses its own entries.
@@ -483,7 +466,6 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
return chatContexts
}
// chunkStrings splits a string slice into fixed-size batches.
func chunkStrings(items []string, chunkSize int) [][]string {
if len(items) == 0 || chunkSize <= 0 {
return nil

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
longQuery := strings.Repeat("q", 81)
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
var body map[string]interface{}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("decode request body: %w", err)
}
if got, _ := body["query"].(string); got != longQuery {
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
runtime.Format = "json"
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImChatSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
runtime := newMessagesSearchRuntime(t, map[string]string{
"query": "incident",
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Format = "json"
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
cmd.Flags().String(name, "", "")
}
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
cmd.Flags().Bool(name, false, "")
}
cmd.Flags().Int("page-size", 20, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("query", query); err != nil {
t.Fatalf("Flags().Set(query) error = %v", err)
}
return cmd
}
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
t.Helper()
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
}
var env map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("envelope data missing or wrong type: %#v", env)
}
return data
}

View File

@@ -159,7 +159,6 @@ var MailTriage = common.Shortcut{
var messages []map[string]interface{}
var hasMore bool
var nextPageToken string
var notice string
useSearch, err := resolveTriagePath(parsed, query, filter)
if err != nil {
@@ -190,9 +189,6 @@ var MailTriage = common.Shortcut{
if err != nil {
return err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
messages = append(messages, pageMessages...)
pageHasMore, _ := searchData["has_more"].(bool)
@@ -286,14 +282,8 @@ var MailTriage = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
@@ -770,7 +760,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderIDFromFilter
}
} else {
params["folder_id"] = folderIDFromFilter
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
}
} else if folderFromFilter != "" {
if dryRun {
@@ -780,7 +776,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderFromFilter
}
} else {
params["folder_id"] = folderFromFilter
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
}
}
@@ -799,7 +801,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelIDFromFilter
}
} else {
params["label_id"] = labelIDFromFilter
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
}
} else if labelFromFilter != "" {
if dryRun {
@@ -809,7 +817,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelFromFilter
}
} else {
params["label_id"] = labelFromFilter
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
}
}

View File

@@ -12,7 +12,6 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -975,11 +974,7 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "sent"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
got, err := buildListParams(rt, "me", f, 20, "", true)
if err != nil {
t.Fatal(err)
}
@@ -988,30 +983,10 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "team-folder"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
if err != nil {
t.Fatal(err)
}
if got["folder_id"] != "team-folder" {
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
}
}
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "flagged"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
got, err := buildListParams(rt, "me", f, 10, "", true)
if err != nil {
t.Fatal(err)
}
@@ -1020,25 +995,6 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "custom-label"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
if err != nil {
t.Fatal(err)
}
if _, ok := got["folder_id"]; ok {
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
}
if got["label_id"] != "custom-label" {
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
}
}
// --- buildSearchParams additional coverage ---
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
@@ -1522,16 +1478,14 @@ func boolPtr(v bool) *bool { return &v }
// --- mailbox_id preservation tests ---
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
tests := []struct {
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
wantNotice string
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
}{
{
name: "list json default mailbox",
@@ -1568,10 +1522,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageSearchStub(reg, mailbox, []interface{}{
mailTriageSearchItem("search_pub_001", "Shared search"),
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
}, false, "")
},
wantCount: 1,
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
wantCount: 1,
},
{
name: "empty list json keeps top-level mailbox",
@@ -1606,9 +1559,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
@@ -1622,7 +1572,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
}
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
@@ -1655,7 +1604,6 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
name string
@@ -1706,33 +1654,6 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
}
}
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageSearchStub(reg, "me", []interface{}{
mailTriageSearchItem("msg_search_notice", "Search notice result"),
}, false, "", notice)
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
t.Fatalf("stdout should contain table row, got:\n%s", out)
}
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
}
}
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
t.Helper()
var data map[string]interface{}
@@ -1742,7 +1663,6 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
return data
}
// mailTriageMessagesFromOutput extracts triage messages as object maps.
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
@@ -1795,8 +1715,7 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
})
}
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
@@ -1804,9 +1723,6 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
if pageToken != "" {
data["page_token"] = pageToken
}
if len(notices) > 0 && notices[0] != "" {
data["notice"] = notices[0]
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: mailboxPath(mailbox, "search"),
@@ -1835,137 +1751,3 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
},
}
}
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
// mailbox folders list API. Because it is non-reusable, any second hit returns
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
// use to prove resolveListFilter runs once and buildListParams does NOT
// re-resolve. folderID/folderName is the single custom folder the API reports.
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: mailboxPath(mailbox, "folders"),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": folderID,
"name": folderName,
},
},
},
},
})
}
// registerMailTriageListPageStub registers one page of the messages list API,
// disambiguated from sibling pages by a URL substring unique to that page
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
// must NOT depend on query-param ordering: map iteration makes param order
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
// so reg.Verify catches under- or over-consumption.
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
}
if pageToken != "" {
data["page_token"] = pageToken
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: urlSubstring,
Body: map[string]interface{}{
"code": 0,
"data": data,
},
})
}
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
// for the bug where buildListParams re-called resolveFolderID on every list
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
// easily tripping rate limits.
//
// Setup: a custom folder filter that forces resolveListFilter to hit the
// folders list API once (to map folder name "team-folder" to folder_id), then two
// messages-list pages. The folders list stub is non-reusable, so if
// buildListParams re-resolves, the second hit fails with "no stub". The
// messages-list stubs are page-specific (disambiguated by page_size in the
// URL), so both pages are served and Verify asserts each fired exactly once.
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
// listMailboxFolders (called once by resolveListFilter) gates on the
// mail:user_mailbox.folder:read scope, which the default test token does
// not carry. Re-store the token with that scope appended so the folders
// API call is actually exercised (and thus the non-reusable folders stub
// is the load-bearing "exactly once" assertion).
const folderScope = "mail:user_mailbox.folder:read"
cfg := mailTestConfig()
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
if !strings.Contains(stored.Scope, folderScope) {
stored.Scope = stored.Scope + " " + folderScope
if err := auth.SetStoredToken(stored); err != nil {
t.Fatalf("re-store token with folder scope: %v", err)
}
}
}
const (
mailbox = "me"
folderName = "team-folder"
folderID = "fld_custom_team"
page2Token = "tok_page2"
)
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
// on page 2. The page_size query value disambiguates the two list stubs.
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
page2IDs := []string{"msg_d", "msg_e"}
// Folders list: registered exactly once, non-reusable. Any second folder
// lookup (the bug) fails the test with "no stub for GET .../folders".
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
// Messages list, page 2: 2 ids, terminal.
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
// Batch metadata fetch for all 5 ids.
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_a", "Subject A"),
mailTriageBatchMessage("msg_b", "Subject B"),
mailTriageBatchMessage("msg_c", "Subject C"),
mailTriageBatchMessage("msg_d", "Subject D"),
mailTriageBatchMessage("msg_e", "Subject E"),
})
args := []string{
"+triage",
"--as", "user",
"--mailbox", mailbox,
"--filter", `{"folder":"` + folderName + `"}`,
"--max", "5",
"--format", "json",
}
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
if got := data["has_more"]; got != false {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
// non-reusable; reg.Verify (deferred above) asserts each was matched
// exactly once. Combined with the non-reusable folders stub, this is the
// proof that the folders list API was called exactly once across both
// pages — the core invariant the fix restores.
}

View File

@@ -308,9 +308,6 @@ var MinutesSearch = common.Shortcut{
"has_more": data["has_more"],
"page_token": data["page_token"],
}
if notice, _ := data["notice"].(string); notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
if len(rows) == 0 {

View File

@@ -609,8 +609,6 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
t.Parallel()
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -619,7 +617,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{
nil,
map[string]interface{}{
@@ -644,9 +641,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
reg.Verify(t)
var envelope struct {
Data struct {
Notice string `json:"notice"`
} `json:"data"`
Meta struct {
Count int `json:"count"`
} `json:"meta"`
@@ -657,9 +651,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
}
if envelope.Data.Notice != notice {
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
}
}
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.

View File

@@ -177,18 +177,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,
@@ -444,7 +432,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
t, tc.shortcut,
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
)
requireValidation(t, standaloneErr, tc.wantContains)
if standaloneErr == nil {
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
}
// Batch path: translate the matching sub-op. The translator wraps
// the inner error with "operations[i] (<shortcut>): " — assert the
@@ -458,12 +451,17 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
"input": subInput,
}
_, batchErr := translateBatchOp(rawOp, testToken, 0)
batchVE := requireValidation(t, batchErr, tc.wantContains)
if batchErr == nil {
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(batchErr.Error(), tc.wantContains) {
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
}
// And the wrap context must include the sub-op index + shortcut
// name so error reports stay actionable in multi-op batches.
wrapHint := "operations[0] (" + tc.subShortcut + "):"
if !strings.Contains(batchVE.Message, wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
if !strings.Contains(batchErr.Error(), wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
}
})
}
@@ -519,7 +517,12 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, tc.wantContains)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
@@ -577,7 +580,12 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, tc.wantContains)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
@@ -708,7 +716,12 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, tc.wantContains)
if err == nil {
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
@@ -769,7 +782,12 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, tc.wantContains)
if err == nil {
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}

View File

@@ -150,12 +150,6 @@ var batchOpDispatch = map[string]batchOpMapping{
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},

View File

@@ -4,10 +4,12 @@
package sheets
import (
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
@@ -35,9 +37,18 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
// Bare value naming an existing file → guarded with a fix-it hint.
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
ve := requireValidation(t, err, "existing file")
if !strings.Contains(ve.Message, "@data.csv") {
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
if err == nil {
t.Fatal("expected guard error when --csv names an existing file")
}
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("problem = %+v, want validation/invalid_argument", p)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--csv" {
t.Errorf("param = %q, want --csv", ve.Param)

View File

@@ -4,6 +4,7 @@
package sheets
import (
"strings"
"testing"
)
@@ -43,7 +44,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
"range": "A1:H17",
})
_, err := csvPutInput(fv, "tok", "sid", "")
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
if err == nil {
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
}
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
}
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
@@ -55,7 +61,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
_, err := csvPutInput(fv, "tok", "sid", "")
requireValidation(t, err, "--start-cell or --range is required")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
}
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see

View File

@@ -54,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position (0-based); appended to the end when omitted",
"desc": "Insert position; appended to the end when omitted",
"default": "-1"
},
{
@@ -413,86 +413,6 @@
}
]
},
"+sheet-hide-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-show-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-create": {
"risk": "write",
"flags": [
@@ -510,46 +430,28 @@
"required": "optional",
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"input": [
"file",
"stdin"
]
},
{
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"input": [
"file",
"stdin"
]
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"input": [
"file",
"stdin"
]
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
},
{
"name": "dry-run",
"kind": "system",
@@ -600,7 +502,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
"desc": "Local save path; export is triggered but not downloaded when omitted"
},
{
"name": "dry-run",
@@ -611,32 +513,6 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1206,8 +1082,9 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
},
{
"name": "skip-hidden",
@@ -1315,8 +1192,9 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
},
{
"name": "include-row-prefix",
@@ -1334,72 +1212,19 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "no-header",
"name": "rows-json",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dataframe-out",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
"desc": "Print the request path and parameters without executing"
}
]
},
@@ -2024,7 +1849,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"input": [
"file",
"stdin"
@@ -2055,61 +1880,6 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [

View File

@@ -1,5 +1,5 @@
{
"schema_version": "3",
"schema_version": "2",
"flags": {
"+batch-update": {
"operations": {
@@ -44,8 +44,6 @@
"+sheet-hide",
"+sheet-unhide",
"+sheet-set-tab-color",
"+sheet-show-gridline",
"+sheet-hide-gridline",
"+chart-create",
"+chart-update",
"+chart-delete",
@@ -456,7 +454,7 @@
"type": "object"
},
"link": {
"description": "超链接地址type='link' 时必填)@文档 mentionmention_type 非 0时也必填传文档 URL如搜索结果里的文档链接否则卡片不可点。@人mention_type=0不需要传",
"description": "超链接地址(type='link' 时必填)",
"type": "string"
},
"mention_token": {
@@ -464,21 +462,8 @@
"type": "string"
},
"mention_type": {
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
"type": "number",
"enum": [
0,
1,
3,
8,
11,
12,
15,
16,
22,
30,
38
]
"description": "@提及类型编号(仅 type='mention' 时可选)",
"type": "number"
},
"notify": {
"description": "是否发送通知(仅 type='mention' 时可选,默认 true",
@@ -1745,12 +1730,11 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1803,7 +1787,11 @@
"data"
]
}
}
},
"required": [
"position",
"size"
]
}
},
"+chart-update": {
@@ -2781,12 +2769,11 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2839,7 +2826,11 @@
"data"
]
}
}
},
"required": [
"position",
"size"
]
}
},
"+cond-format-create": {
@@ -6258,744 +6249,6 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
}
}
}

View File

@@ -47,132 +47,19 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
"data": map[string]interface{}{},
},
}
_, _, err := func() (string, string, error) {
stdout, stderr, err := func() (string, string, error) {
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
reg.Register(stub)
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
err := parent.Execute()
return stdout.String(), stderr.String(), err
}()
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
}
}
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
// test would fail if the node_token were used unresolved.
func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
out, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
}
}
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
// non-spreadsheet obj_type before any tool invoke.
func TestExecute_WikiURLWrongObjType(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docABC",
},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
requireValidation(t, err, "obj_type")
}
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
// (missing obj_type/obj_token) as an internal/server error, not a user --url
// validation error.
func TestExecute_WikiURLIncompleteNode(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
if err == nil {
t.Fatal("want error for incomplete get_node node data")
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
}
}
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
// and +range-copy use a named Execute helper (not an inline func), so they must
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
// transform_range. The tool stub is keyed on the resolved obj_token, so an
// unresolved node_token would miss it and fail this test.
func TestExecute_RangeMove_WikiURL(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
out, err := runShortcutWithStubs(t, RangeMove,
[]string{
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
"--sheet-id", testSheetID,
"--source-range", "A1:B2",
"--target-range", "A10",
}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
}
}
@@ -478,17 +365,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
},
},
}
// The write reads the workbook structure to resolve the default sheet's id
// (the create response doesn't echo it). lookupFirstSheetID and
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
// so it's adopted in place (no rename).
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
@@ -498,8 +382,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
@@ -509,13 +393,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
// (no structure/fill calls fire) and finish with the spreadsheet created but no
// sheets summary — never panic on a nil payload.
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -536,8 +421,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["sheets"] != nil {
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])

View File

@@ -80,28 +80,3 @@ func flagsFor(command string) []common.Flag {
}
return out
}
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
// error should also steer the caller toward stdin. It runs on an error path,
// so it returns false for an unknown command/flag rather than panicking the
// way flagsFor does.
func flagAcceptsStdin(command, name string) bool {
defs, _ := loadFlagDefs()
spec, ok := defs[command]
if !ok {
return false
}
for _, df := range spec.Flags {
if df.Name != name {
continue
}
for _, in := range df.Input {
if in == common.Stdin {
return true
}
}
return false
}
return false
}

View File

@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -305,9 +305,10 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -319,7 +320,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -765,7 +766,7 @@ var flagDefs = map[string]commandDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -792,16 +793,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
@@ -848,16 +839,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
@@ -914,39 +895,13 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -957,18 +912,10 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
if url == nil || url.Required {
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
}
// visible + int default
// hidden + int default
cap := byName("+cells-get", "max-chars")
if cap == nil || cap.Hidden || cap.Default != "500000" {
if cap == nil || !cap.Hidden || cap.Default != "200000" {
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
}
// input sources
@@ -140,24 +140,3 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
}
}
}
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
// an "invalid JSON" error should also steer the caller toward stdin: a composite
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
// an unknown command/flag returns false without panicking (it runs on an error
// path, unlike flagsFor).
func TestFlagAcceptsStdin(t *testing.T) {
t.Parallel()
if !flagAcceptsStdin("+cells-set", "cells") {
t.Error("+cells-set --cells should accept stdin")
}
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
t.Error("--spreadsheet-token should not accept stdin")
}
if flagAcceptsStdin("+nope", "cells") {
t.Error("unknown command should be false (and must not panic)")
}
if flagAcceptsStdin("+cells-set", "nope") {
t.Error("unknown flag should be false")
}
}

View File

@@ -9,8 +9,6 @@ import (
"fmt"
"sort"
"sync"
"github.com/larksuite/cli/errs"
)
// ─── --print-schema runtime introspection ─────────────────────────────
@@ -93,7 +91,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
}
if flagName == "" {
flags := make([]string, 0, len(entry))
@@ -114,9 +112,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
WithParam("--flag-name")
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}

View File

@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
t.Parallel()
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
ve := requireValidation(t, err, "+chart-create")
if !strings.Contains(ve.Message, "properties") {
t.Errorf("message should list available flags; got %q", ve.Message)
if err == nil {
t.Fatal("expected error for unknown flag, got nil")
}
if ve.Param != "--flag-name" {
t.Errorf("param = %q, want --flag-name", ve.Param)
msg := err.Error()
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
t.Errorf("error should mention shortcut + available flags; got %q", msg)
}
}

View File

@@ -63,7 +63,6 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
var parseJSONFlagSkip = map[string]struct{}{
"properties": {},
"operations": {},
"styles": {},
}
// validateValueAgainstSchema is the (command, flag) → schema → check
@@ -94,17 +93,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape.
// Point them straight at --print-schema, which dumps the exact JSON
// Schema for this (command, flag) pair. The hint is always actionable:
// reaching this branch means entry[name] resolved a schema from the
// embedded index, and --print-schema reads that same index, so the
// suggested command is guaranteed to print it.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name)
return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error())
}
return nil
}

View File

@@ -478,9 +478,11 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
ve := requireValidation(t, err, "summarize_by")
if !strings.Contains(ve.Message, "not in enum") {
t.Errorf("error = %q, want enum hint", ve.Message)
if err == nil {
t.Fatal("expected enum violation, got nil")
}
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
}
}
@@ -497,9 +499,11 @@ func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
ve := requireValidation(t, err, "values")
if !strings.Contains(ve.Message, "minimum is 1") {
t.Errorf("error = %q, want minimum-is-1 hint", ve.Message)
if err == nil {
t.Fatal("expected minItems violation for empty values, got nil")
}
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
}
}
@@ -516,9 +520,11 @@ func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
ve := requireValidation(t, err, "row")
if !strings.Contains(ve.Message, "below minimum") {
t.Errorf("error = %q, want below-minimum hint", ve.Message)
if err == nil {
t.Fatal("expected minimum violation for row:-1, got nil")
}
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
}
}
@@ -548,9 +554,11 @@ func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
ve := requireValidation(t, err, "collapse")
if !strings.Contains(ve.Message, `expected type "string"`) {
t.Errorf("error = %q, want string-type hint", ve.Message)
if err == nil {
t.Fatal("expected additionalProperties violation, got nil")
}
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
}
}
@@ -579,24 +587,3 @@ func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
t.Errorf("operations should be skipped; got %v", err)
}
}
// TestValidateValueAgainstSchema_PrintSchemaHint pins the highest-value
// recovery affordance for composite-JSON flags: when the shape is wrong, the
// error must point the agent straight at --print-schema (with the right
// command + flag) instead of leaving it to guess across retries. +cells-set
// --cells expects a 2-D array; a bare string trips the top-level type check.
func TestValidateValueAgainstSchema_PrintSchemaHint(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
err := validateValueAgainstSchema(fv, "cells", "not-an-array")
// Underlying shape error is preserved (substring callers still match).
ve := requireValidation(t, err, `expected type "array"`)
// And the actionable --print-schema hint is appended with the exact
// command + flag, so a copy-paste fetches the schema for this pair.
if !strings.Contains(ve.Message, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
t.Errorf("want --print-schema hint with command+flag; got %q", ve.Message)
}
if ve.Param != "--cells" {
t.Errorf("param = %q, want --cells", ve.Param)
}
}

View File

@@ -32,6 +32,4 @@ var commandsWithSchema = map[string]struct{}{
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -10,8 +10,6 @@ package sheets
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -50,151 +48,46 @@ func sheetsInputStatError(flag string, err error) error {
return wrapped
}
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
// wiki node that must be resolved to its backing spreadsheet at Execute time.
const (
spreadsheetRefSheet = "sheet"
spreadsheetRefWiki = "wiki"
)
// spreadsheetRef is a parsed --url / --spreadsheet-token input. A wiki ref holds
// the still-unresolved wiki node_token; resolveSpreadsheetTokenExec turns it
// into the real spreadsheet token at Execute time.
type spreadsheetRef struct {
Kind string // spreadsheetRefSheet | spreadsheetRefWiki
Token string
}
// parseSpreadsheetRef applies the public --url / --spreadsheet-token XOR pair and
// classifies the input. Network-free, safe to call from Validate and DryRun.
//
// Recognized --url shapes:
// - https://.../sheets/<token> → {sheet, token}
// - https://.../spreadsheets/<token> → {sheet, token}
// - https://.../wiki/<node_token> → {wiki, node_token} (resolved at Execute)
//
// A raw --spreadsheet-token is always treated as a spreadsheet token; wiki nodes
// only ever arrive as a /wiki/ URL.
func parseSpreadsheetRef(runtime *common.RuntimeContext) (spreadsheetRef, error) {
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return spreadsheetRef{}, err
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return spreadsheetRef{}, sheetsValidationCauseForFlag("spreadsheet-token", err)
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
}
return spreadsheetRef{Kind: spreadsheetRefSheet, Token: token}, nil
return token, nil
}
rawURL := strings.TrimSpace(runtime.Str("url"))
token, kind, ok := spreadsheetURLToken(rawURL)
if !ok {
return spreadsheetRef{}, sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token> or a wiki URL like https://.../wiki/<token>")
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return spreadsheetRef{}, sheetsValidationCauseForFlag("url", err)
return "", sheetsValidationCauseForFlag("url", err)
}
return spreadsheetRef{Kind: kind, Token: token}, nil
return token, nil
}
// spreadsheetURLToken extracts the token and its kind from a Lark URL, matching
// only on the URL *path* segment (parsed via net/url). A /wiki/ or /sheets/ that
// appears only in the query or fragment (e.g. a redirect or anchor param) never
// hijacks classification. Returns ok=false when no known prefix heads the path.
func spreadsheetURLToken(rawURL string) (token, kind string, ok bool) {
u, err := neturl.Parse(rawURL)
if err != nil || u.Path == "" {
return "", "", false
}
for _, m := range []struct {
prefix string
kind string
}{
{"/sheets/", spreadsheetRefSheet},
{"/spreadsheets/", spreadsheetRefSheet},
{"/wiki/", spreadsheetRefWiki},
} {
if seg, found := pathSegmentAfter(u.Path, m.prefix); found {
return seg, m.kind, true
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
// prefix is present (callers must check token != originalInput).
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
}
}
return "", "", false
}
// pathSegmentAfter returns the first path segment after prefix when path begins
// with prefix, else ("", false).
func pathSegmentAfter(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
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR pair
// and returns the resolved token. Network-free, safe to call from Validate and
// DryRun.
//
// A /wiki/ URL yields the still-unresolved wiki node_token: turning it into the
// backing spreadsheet token needs a get_node call, which only Execute may make.
// Validate/DryRun only need a non-empty, control-char-clean token, so the
// node_token passes through unchanged here; Execute paths call
// resolveSpreadsheetTokenExec instead.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
return ref.Token, nil
}
// resolveSpreadsheetTokenExec is the Execute-time counterpart of
// resolveSpreadsheetToken: it additionally resolves a /wiki/ URL's node_token to
// the backing spreadsheet token via wiki get_node, verifying obj_type=sheet.
// Non-wiki inputs make no API call. Use this from every sheets Execute hook and
// keep resolveSpreadsheetToken in Validate/DryRun so those stay network-free.
func resolveSpreadsheetTokenExec(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
if ref.Kind != spreadsheetRefWiki {
return ref.Token, nil
}
return resolveWikiNodeToSpreadsheetToken(runtime, ref.Token)
}
// resolveWikiNodeToSpreadsheetToken resolves a wiki node_token to the spreadsheet
// obj_token it points at, erroring when the node is not a spreadsheet. The
// wiki:node:read scope is only needed on this path, so it is enforced here rather
// than declared unconditionally on every sheets shortcut.
func resolveWikiNodeToSpreadsheetToken(runtime *common.RuntimeContext, nodeToken string) (string, error) {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return "", err
}
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": nodeToken}, 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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data for %q", nodeToken)
}
if objType != "sheet" {
return "", sheetsValidationForFlag("url", "wiki URL resolves to obj_type=%q, but a spreadsheet (obj_type=sheet) is required", objType)
}
return objToken, nil
return input
}
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
@@ -348,16 +241,6 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
// Composite payloads that embed formulas / quotes / commas are the
// classic source of this error: inlined into the shell, the JSON gets
// mangled (e.g. `\$` → "invalid character in string escape"). For any
// flag that accepts stdin, steer the caller there — passing the payload
// via `--<flag> - < file` sidesteps shell escaping entirely.
if flagAcceptsStdin(runtime.Command(), name) {
return nil, sheetsValidationForFlag(name,
"--%s: invalid JSON: %v; if the payload contains formulas / quotes / commas, pass it via stdin (`--%s - < file`) so the shell doesn't mangle the JSON",
name, err, name).WithCause(err)
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Schema-driven flag validation at the user-input boundary. Skips
@@ -442,72 +325,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {

View File

@@ -81,53 +81,6 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
return stdout.String(), err
}
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
//
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
// across sheets tests: substring on a rendered envelope was brittle (any
// message tweak silently broke it) and didn't verify that the typed contract —
// category / subtype / cause preservation — held. Per coding guideline
// "Error-path tests must assert typed metadata via errs.ProblemOf
// (category / subtype / param) and cause preservation, not message substrings
// alone."
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for the most common case: a typed
// CategoryValidation error with SubtypeInvalidArgument. Returns the
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}
func TestSheetHelpersValidationMetadata(t *testing.T) {
t.Parallel()
@@ -315,52 +268,3 @@ const (
testSheetID = "shtSubA"
testSheetID2 = "shtSubB"
)
// TestParseSpreadsheetRef locks the network-free classification of
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
// node_token. The wiki node is resolved later, at Execute time only.
func TestParseSpreadsheetRef(t *testing.T) {
t.Parallel()
mk := func(url, tok string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", url, "")
cmd.Flags().String("spreadsheet-token", tok, "")
return common.TestNewRuntimeContext(cmd, testConfig(t))
}
cases := []struct {
name string
url string
tok string
wantKind string
wantToken string
wantErr bool
}{
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
{name: "neither provided", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
if tc.wantErr {
if err == nil {
t.Fatalf("want error, got ref=%+v", ref)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
}
})
}
}

View File

@@ -67,7 +67,7 @@ var BatchUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -180,7 +180,7 @@ var CellsBatchSetStyle = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -270,7 +270,7 @@ var CellsBatchClear = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -350,7 +350,7 @@ var DropdownUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -396,7 +396,7 @@ var DropdownDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ package sheets
import (
"encoding/json"
"strings"
"testing"
)
@@ -165,16 +166,18 @@ func TestCellsBatchClear_Guards(t *testing.T) {
t.Parallel()
// sheetless range → prefix guard (shared with the dropdown fan-outs).
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["A1:A10"]`,
"--yes",
"--dry-run",
})
requireValidation(t, err, "must include a sheet prefix")
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
}
// missing --yes → confirmation_required (high-risk-write).
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10"]`,
})
@@ -265,32 +268,38 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
t.Parallel()
// dropdown-update with sheetless range
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["A2:A5"]`,
"--options", `["a"]`,
"--dry-run",
})
requireValidation(t, err, "must include a sheet prefix")
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
}
// batch-update with empty operations
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[]`,
"--yes",
"--dry-run",
})
requireValidation(t, err, "non-empty JSON array")
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
}
// dropdown-update with non-array --options (object instead) → array guard
// (now via schema validator at parseJSONFlag time)
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A2"]`,
"--options", `{"not":"array"}`,
"--dry-run",
})
requireValidation(t, err, `expected type "array"`)
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
@@ -313,13 +322,15 @@ func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
requireValidation(t, err, tc.want)
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
}
})
}
}
@@ -408,13 +419,18 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
requireValidation(t, err, tc.wantMatch)
if err == nil {
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
}
})
}
}

View File

@@ -1,621 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
//
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
// writes), single schema, optionally multi-batch. Type / format are read off
// the Arrow schema (no separate dtypes/formats maps), and per-column number
// format can be set via the field's `number_format` metadata key:
//
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
//
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
// (adopted in place by +workbook-create; created when absent by +table-put),
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
// surface is deliberately the one flag — anything that needs a different
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
// JSON payload already carries every knob.
//
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
// stream byte-exact. "-" reads from stdin directly.
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
// Matches valuesSheetName so +workbook-create adopts the brand-new
// workbook's default sheet in place (no stray empty Sheet1 left behind);
// +table-put creates Sheet1 if it doesn't already exist.
const dataframeDefaultSheetName = valuesSheetName
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
// composes a single-sheet tablePayload at the fixed default placement.
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
// so downstream is unaware of where the rows came from.
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
raw := strings.TrimSpace(rctx.Str("dataframe"))
if raw == "" {
return nil, common.ValidationErrorf("--dataframe is required")
}
data, err := readDataframeBytes(rctx, raw)
if err != nil {
return nil, err
}
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
if err := payload.validate(); err != nil {
return nil, err
}
return payload, nil
}
// dataframeStdinCache holds the bytes read from stdin on the first call so a
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
// empty stream — stdin is single-shot, but parseDataframePayload runs
// multiple times per command invocation. Process-wide is fine: lark-cli is
// one-shot (one command per process). Tests reset by setting it back to nil.
var dataframeStdinCache []byte
// Memory caps for --dataframe. The Arrow IPC reader allocates large buffers up
// front, and arrowRecordToRows materializes every cell into [][]interface{}, so
// an unbounded input could OOM the CLI before the backend's per-write limits
// kicked in. The caps mirror the backend's per-sheet hard ceilings (200 cols,
// 50000 rows) plus a generous overall byte cap that still fits the worst-case
// dense numeric payload (200 × 50000 cells × ~25 bytes Arrow overhead ≈ 250 MB).
const (
dataframeMaxBytes = 256 * 1024 * 1024 // 256 MiB raw IPC payload
dataframeMaxCols = 200 // backend hard ceiling
dataframeMaxRows = 50000 // backend hard ceiling
)
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
// both work). `-` reads stdin verbatim — cached on first call so Validate /
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
// readCapped pulls up to dataframeMaxBytes+1 bytes from r so we can detect
// "exceeded cap" without allocating the entire oversized payload up front.
readCapped := func(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, dataframeMaxBytes+1))
if err != nil {
return nil, err
}
if len(data) > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: payload exceeds %d MiB cap (limits CLI memory; the backend per-sheet ceilings are 200 cols × 50000 rows)",
dataframeMaxBytes/(1024*1024))
}
return data, nil
}
if raw == "-" {
if dataframeStdinCache != nil {
return dataframeStdinCache, nil
}
// A process has a single stdin: --dataframe is binary and bypasses the
// common Input resolver, so we have to share the stdin-consumed flag with
// it explicitly. Without this, e.g. `+table-put --dataframe - --styles -`
// would be accepted and one of them would silently see an empty stream.
if rctx.StdinConsumed() {
return nil, common.ValidationErrorf("--dataframe: stdin (-) can only be used by one flag").
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --styles @/path/to/styles.json)")
}
ios := rctx.IO()
if ios == nil || ios.In == nil {
return nil, common.ValidationErrorf("--dataframe: stdin is not available")
}
data, err := readCapped(ios.In)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: read stdin: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: stdin is empty")
}
dataframeStdinCache = data
rctx.MarkStdinConsumed()
return data, nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return nil, common.ValidationErrorf("--dataframe: file input is not available in this context")
}
// Pre-check size via Stat so a multi-GB file is rejected immediately
// instead of being streamed all the way to the cap.
if info, statErr := fio.Stat(path); statErr == nil && info.Size() > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: file %q is %d MiB, exceeds %d MiB cap",
path, info.Size()/(1024*1024), dataframeMaxBytes/(1024*1024))
}
f, err := fio.Open(path)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
defer f.Close()
data, err := readCapped(f)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: file %q is empty", path)
}
return data, nil
}
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
// not touched here — parseDataframePayload layers those on from CLI flags.
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
reader, err := ipc.NewFileReader(bytes.NewReader(data))
if err != nil {
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
defer reader.Close()
schema := reader.Schema()
if schema == nil || schema.NumFields() == 0 {
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
ncols := schema.NumFields()
if ncols > dataframeMaxCols {
// Fail fast at the schema layer before allocating per-column slices.
// 200 cols matches the backend's per-sheet hard ceiling — anything past
// that would error on the first set_cell_range anyway.
return tableSheetSpec{}, fmt.Errorf("%d columns exceeds the per-sheet ceiling of %d", ncols, dataframeMaxCols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols := make([]tableColumnSpec, ncols)
seen := make(map[string]bool, ncols)
for i := 0; i < ncols; i++ {
f := schema.Field(i)
name := f.Name
if strings.TrimSpace(name) == "" {
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if seen[name] {
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
seen[name] = true
typ, format, err := arrowFieldToTypeFormat(f)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("column %q: %w", name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
}
var rows [][]interface{}
for b := 0; b < reader.NumRecords(); b++ {
rec, err := reader.RecordAt(b)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %w", b, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// Reject early during iteration before materializing more rows into the
// [][]interface{} buffer — without this, a 1M-row Arrow file would be
// fully decoded into memory before the writer's per-batch size check
// kicks in.
if int64(len(rows))+rec.NumRows() > int64(dataframeMaxRows) {
rec.Release()
return tableSheetSpec{}, fmt.Errorf("%d rows exceeds the per-sheet ceiling of %d", int64(len(rows))+rec.NumRows(), dataframeMaxRows) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
batchRows, err := arrowRecordToRows(rec, cols)
rec.Release()
if err != nil {
return tableSheetSpec{}, err
}
rows = append(rows, batchRows...)
}
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
}
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
// pair. The field's `number_format` metadata key — when present — sets the
// Excel number_format string verbatim; otherwise sensible defaults are
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
if v, ok := f.Metadata.GetValue("number_format"); ok {
format = strings.TrimSpace(v)
}
switch f.Type.(type) {
case *arrow.StringType, *arrow.LargeStringType:
if format == "" {
format = "@"
}
return "string", format, nil
case *arrow.BooleanType:
return "bool", format, nil
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
if format == "" {
format = "yyyy-mm-dd"
}
return "date", format, nil
}
if isArrowNumericType(f.Type) {
return "number", format, nil
}
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name()) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
func isArrowNumericType(t arrow.DataType) bool {
switch t.ID() {
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
return true
}
return false
}
// arrowRecordToRows transposes one column-batch into row-major
// [][]interface{} matched to `cols`. Cells are stamped with the same value
// shapes buildTypedCell expects from the JSON path: nil for nulls,
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
// dates/timestamps, bool for booleans, string for strings.
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
if int(rec.NumCols()) != len(cols) {
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols)) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
nrows := int(rec.NumRows())
rows := make([][]interface{}, nrows)
for r := range rows {
rows[r] = make([]interface{}, len(cols))
}
for c := range cols {
arr := rec.Column(c)
for r := 0; r < nrows; r++ {
if arr.IsNull(r) {
continue
}
v, err := arrowCellValue(arr, r)
if err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, cols[c].Name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
rows[r][c] = v
}
}
return rows, nil
}
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
switch a := arr.(type) {
case *array.String:
return a.Value(i), nil
case *array.LargeString:
return a.Value(i), nil
case *array.Boolean:
return a.Value(i), nil
case *array.Int8:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int16:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int32:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int64:
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
case *array.Uint8:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint16:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint32:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint64:
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
case *array.Float16:
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
case *array.Float32:
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
case *array.Float64:
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
case *array.Date32:
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
// in UTC so timezone offset can't flip the calendar date.
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
return t.Format("2006-01-02"), nil
case *array.Date64:
t := time.UnixMilli(int64(a.Value(i))).UTC()
return t.Format("2006-01-02"), nil
case *array.Timestamp:
ts := int64(a.Value(i))
unit := a.DataType().(*arrow.TimestampType).Unit
var t time.Time
switch unit {
case arrow.Second:
t = time.Unix(ts, 0).UTC()
case arrow.Millisecond:
t = time.UnixMilli(ts).UTC()
case arrow.Microsecond:
t = time.UnixMicro(ts).UTC()
case arrow.Nanosecond:
t = time.Unix(0, ts).UTC()
default:
return nil, fmt.Errorf("unsupported timestamp unit %v", unit) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return t.Format("2006-01-02"), nil
}
return nil, fmt.Errorf("unsupported Arrow array %T", arr) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
//
// +table-get's binary read-back: encode one sheet's typed read-back as an
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
// is unchanged; --dataframe-out swaps the encoder for callers that already
// have pandas / pyarrow in their pipeline.
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
// file blob. Internal column types are recovered from `dtypes` (the wire
// proxy for the typed protocol), and per-column `number_format` rides through
// as Arrow field metadata so the file feeds straight back into
// `+table-put --dataframe`.
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
columns, _ := sheet["columns"].([]interface{})
if len(columns) == 0 {
return nil, fmt.Errorf("sheet has no columns") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
dtypes, _ := sheet["dtypes"].(map[string]interface{})
formats, _ := sheet["formats"].(map[string]interface{})
// `data` arrives as either []interface{} (when the sheet came through a
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
// readSheetAsSpec directly emits in production). Accept both — anything
// else falls through to a zero-row table.
var rawData [][]interface{}
switch d := sheet["data"].(type) {
case [][]interface{}:
rawData = d
case []interface{}:
rawData = make([][]interface{}, len(d))
for i, r := range d {
rawData[i], _ = r.([]interface{})
}
}
ncols := len(columns)
colNames := make([]string, ncols)
colTypes := make([]string, ncols)
fields := make([]arrow.Field, ncols)
for i, c := range columns {
name, _ := c.(string)
if name == "" {
return nil, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
colNames[i] = name
dt, _ := dtypes[name].(string)
colTypes[i] = dtypeToInternalType(dt)
var meta arrow.Metadata
if formats != nil {
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
}
}
fields[i] = arrow.Field{
Name: name,
Type: internalTypeToArrowType(colTypes[i]),
Nullable: true,
Metadata: meta,
}
}
schema := arrow.NewSchema(fields, nil)
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
for r, row := range rawData {
if len(row) != ncols {
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
for c := 0; c < ncols; c++ {
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, colNames[c], err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
}
}
rec := rb.NewRecord()
defer rec.Release()
var buf bytesWriterSeeker
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
return nil, fmt.Errorf("ipc.NewFileWriter: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Write(rec); err != nil {
return nil, fmt.Errorf("write record: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return buf.buf, nil
}
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
// internal column type from the wire-level dtype string. Unknown / object
// falls back to string (lossless: every cell is already typed as such).
func dtypeToInternalType(dtype string) string {
switch strings.ToLower(strings.TrimSpace(dtype)) {
case "float64", "float32", "int64", "int32", "int16", "int8",
"uint64", "uint32", "uint16", "uint8":
return "number"
case "bool", "boolean":
return "bool"
}
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
return "date"
}
return "string"
}
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
// internal column type to the Arrow data type the encoder builds a column
// with. Numbers go to float64 because +table-get can't tell int from float
// from a number_format alone — float64 covers both losslessly for the cell
// ranges Lark Sheets accepts.
func internalTypeToArrowType(typ string) arrow.DataType {
switch typ {
case "number":
return arrow.PrimitiveTypes.Float64
case "date":
return arrow.FixedWidthTypes.Date32
case "bool":
return arrow.FixedWidthTypes.Boolean
}
return arrow.BinaryTypes.String
}
// appendArrowCell stamps one cell into its column builder. Cell shape matches
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
// nil for empty. Anything off-shape errors so the caller doesn't silently
// emit nulls for malformed data.
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
if v == nil {
b.AppendNull()
return nil
}
switch typ {
case "string":
s, ok := v.(string)
if !ok {
return fmt.Errorf("string expects string value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.StringBuilder).Append(s)
case "number":
f, err := arrowNumber(v)
if err != nil {
return err
}
b.(*array.Float64Builder).Append(f)
case "date":
s, ok := v.(string)
if !ok {
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("date parse %q: %w", s, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
case "bool":
bb, ok := v.(bool)
if !ok {
return fmt.Errorf("bool expects bool, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.BooleanBuilder).Append(bb)
default:
return fmt.Errorf("unsupported internal type %q", typ) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return nil
}
// arrowNumber converts the number cell shape readSheetAsSpec emits
// (json.Number) plus the float fallback to float64 for the Arrow builder.
func arrowNumber(v interface{}) (float64, error) {
switch n := v.(type) {
case json.Number:
f, err := n.Float64()
if err != nil {
return 0, fmt.Errorf("number parse %q: %w", n.String(), err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return f, nil
case float64:
return n, nil
}
return 0, fmt.Errorf("number expects numeric value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
// --dataframe-out's stdout path zero-IO and stays straightforward.
type bytesWriterSeeker struct {
buf []byte
pos int64
}
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
end := w.pos + int64(len(p))
if end > int64(len(w.buf)) {
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
}
n := copy(w.buf[w.pos:], p)
w.pos = end
return n, nil
}
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
w.pos = offset
case io.SeekCurrent:
w.pos += offset
case io.SeekEnd:
w.pos = int64(len(w.buf)) + offset
default:
return 0, fmt.Errorf("unknown whence %d", whence) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return w.pos, nil
}
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
if raw == "-" {
out := rctx.IO()
if out == nil || out.Out == nil {
return common.ValidationErrorf("--dataframe-out: stdout is not available")
}
if _, err := out.Out.Write(data); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write stdout").WithCause(err)
}
return nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return common.ValidationErrorf("--dataframe-out: file output is not available in this context")
}
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
// readDataframeBytes hits on the input side) and writes atomically, so we
// don't need an extra ValidatePath call here.
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write %q", path).WithCause(err)
}
return nil
}

View File

@@ -1,421 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/shortcuts/common"
)
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
// Used by the round-trip tests below to stand in for what
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
// depending on a pandas-shaped fixture file.
//
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
// offset), so we write to a temp file and read the bytes back — simpler than
// re-implementing a seekable in-memory buffer.
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
t.Helper()
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
build(rb)
rec := rb.NewRecord()
defer rec.Release()
path := filepath.Join(t.TempDir(), "df.arrow")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create temp arrow file: %v", err)
}
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
f.Close()
t.Fatalf("ipc.NewFileWriter: %v", err)
}
if err := w.Write(rec); err != nil {
t.Fatalf("write record: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close file: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp arrow file: %v", err)
}
return data
}
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
// (type, format) mapping and the per-cell value shape that buildTypedCell
// expects: number cells are json.Number (precision-preserving), date cells
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
// quietly regress any one family.
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "name", Type: arrow.BinaryTypes.String},
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
[]string{"number_format"}, []string{"$#,##0.00"},
)},
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
}, nil)
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
})
spec, err := decodeArrowToSheet(buf, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
if spec.Name != "S1" {
t.Errorf("sheet name = %q, want S1", spec.Name)
}
if len(spec.Columns) != 5 {
t.Fatalf("got %d columns, want 5", len(spec.Columns))
}
want := []struct{ typ, format string }{
{"string", "@"},
{"number", ""},
{"number", "$#,##0.00"},
{"bool", ""},
{"date", "yyyy-mm-dd"},
}
for i, w := range want {
if spec.Columns[i].Type != w.typ {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
}
if spec.Columns[i].Format != w.format {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
// Row 0: every field present, types match what buildTypedCell will accept.
row0 := spec.Rows[0]
if row0[0] != "alice" {
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
}
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
}
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
}
if row0[3] != true {
t.Errorf("row0[active] = %#v, want true", row0[3])
}
if row0[4] != "2024-01-15" {
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
}
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
// so buildTypedCell paints an empty cell that still carries number_format.
row1 := spec.Rows[1]
for _, c := range []int{0, 1, 2} {
if row1[c] != nil {
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
}
}
if row1[3] != false {
t.Errorf("row1[active] = %#v, want false", row1[3])
}
if row1[4] != "2024-02-02" {
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
}
}
// TestDataframe_Timestamp pins the timestamp → date conversion for the
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
// landing — guard against TZ drift from the wrong unit pick.
func TestDataframe_Timestamp(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
}, nil)
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
})
spec, err := decodeArrowToSheet(buf, "S")
if err != nil {
t.Fatal(err)
}
if spec.Columns[0].Type != "date" {
t.Errorf("type = %q, want date", spec.Columns[0].Type)
}
if got := spec.Rows[0][0]; got != "2024-06-12" {
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
}
}
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
// a 0-column "DataFrame" would write a header-less, data-less block that
// validates as "writer ran successfully" but produces nothing — the test ties
// that off as an explicit error rather than letting it slip through.
func TestDataframe_EmptySchema(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema(nil, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "no fields") {
t.Errorf("err = %v, want 'no fields' error", err)
}
}
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
// time. Validate already rejects duplicate column names for the JSON path;
// the Arrow path mirrors that so the error surfaces with the same shape.
func TestDataframe_DuplicateColumn(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "x", Type: arrow.BinaryTypes.String},
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
}, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).Append("")
b.Field(1).(*array.Int64Builder).Append(0)
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("err = %v, want duplicate-column error", err)
}
}
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
// pandas df.to_feather so users see what producer is expected without having
// to grep the docs.
func TestDataframe_BadBytes(t *testing.T) {
t.Parallel()
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
if err == nil || !strings.Contains(err.Error(), "Arrow") {
t.Errorf("err = %v, want Arrow-decode error", err)
}
}
// TestDecodeArrowToSheet_RejectsTooManyColumns guards the per-sheet column cap.
// Without it a wide Arrow file (e.g. 201+ columns) would allocate a long
// tableColumnSpec slice + decode every batch before the backend's 200-column
// per-sheet ceiling rejected the first set_cell_range — wasting both CPU and
// memory before the failure surfaces. Cap matches the backend hard ceiling.
func TestDecodeArrowToSheet_RejectsTooManyColumns(t *testing.T) {
t.Parallel()
fields := make([]arrow.Field, dataframeMaxCols+1)
for i := range fields {
fields[i] = arrow.Field{Name: "c" + strings.TrimSpace(string(rune('0'+i%10))) + "_" + strings.Repeat("x", i/10+1), Type: arrow.BinaryTypes.String}
}
schema := arrow.NewSchema(fields, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
for i := range fields {
b.Field(i).(*array.StringBuilder).Append("")
}
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "exceeds the per-sheet ceiling") {
t.Errorf("err = %v, want column-cap error", err)
}
}
// TestReadDataframeBytes_RejectsSecondStdinConsumer covers the case where another
// flag (e.g. --styles) has already consumed stdin via the common Input resolver:
// since --dataframe bypasses that resolver, the only thing keeping the two from
// racing for an empty stream is the explicit StdinConsumed() check in
// readDataframeBytes. Without that check, fangshuyu's report holds — both flags
// silently accept '-' and one of them sees empty bytes downstream.
func TestReadDataframeBytes_RejectsSecondStdinConsumer(t *testing.T) {
// process-wide cache must be reset so the test isn't served from a prior run.
saved := dataframeStdinCache
dataframeStdinCache = nil
t.Cleanup(func() { dataframeStdinCache = saved })
rctx := &common.RuntimeContext{}
rctx.MarkStdinConsumed()
_, err := readDataframeBytes(rctx, "-")
requireValidation(t, err, "stdin (-) can only be used by one flag")
}
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
// own decoder: build a +table-get-shaped sheet map (the same one
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
// decoder, and require the column types / formats / row values to match. If
// any encoder choice drifts from what the decoder expects, the round-trip
// breaks here long before a real put → get round-trip in production would.
func TestDataframe_EncodeRoundTrip(t *testing.T) {
t.Parallel()
sheet := map[string]interface{}{
"name": "S1",
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
"dtypes": map[string]interface{}{
"name": "object",
"qty": "float64",
"price": "float64",
"active": "bool",
"ts": "datetime64[ns]",
},
"formats": map[string]interface{}{
// `@` is the writer convention for string columns; readSheetAsSpec
// strips it via isTextNumberFormat, so an Arrow file built from a
// real read won't carry @ either. Keep it absent here to mirror
// the production wire shape.
"price": "$#,##0.00",
},
"data": []interface{}{
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
},
}
blob, err := encodeSheetMapToArrowIPC(sheet)
if err != nil {
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
}
spec, err := decodeArrowToSheet(blob, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
wantTypes := []string{"string", "number", "number", "bool", "date"}
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
if len(spec.Columns) != len(wantTypes) {
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
}
for i, w := range wantTypes {
if spec.Columns[i].Type != w {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
}
if spec.Columns[i].Format != wantFormats[i] {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
if spec.Rows[0][0] != "alice" {
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
}
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
}
if spec.Rows[0][3] != true {
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
}
if spec.Rows[0][4] != "2024-01-15" {
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
}
// qty is null on row1, must come back as nil (not a zero-valued
// json.Number that would later round-trip as 0).
if spec.Rows[1][1] != nil {
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
}
}
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
// output — early on the production shape silently fell through the
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
t.Parallel()
base := func(data interface{}) map[string]interface{} {
return map[string]interface{}{
"name": "S",
"columns": []interface{}{"city"},
"dtypes": map[string]interface{}{"city": "object"},
"data": data,
}
}
for label, data := range map[string]interface{}{
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
} {
blob, err := encodeSheetMapToArrowIPC(base(data))
if err != nil {
t.Errorf("%s: encode: %v", label, err)
continue
}
spec, err := decodeArrowToSheet(blob, "S")
if err != nil {
t.Errorf("%s: decode: %v", label, err)
continue
}
if len(spec.Rows) != 2 {
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
}
}
}
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
// dtype families +table-get emits today plus the safe fallback for unknown
// labels (string, lossless).
func TestDataframe_DtypeToInternalType(t *testing.T) {
t.Parallel()
cases := map[string]string{
"float64": "number",
"int64": "number",
"Int64": "number",
"bool": "bool",
"boolean": "bool",
"datetime64[ns]": "date",
"datetime64[ms]": "date",
"object": "string",
"": "string",
"weird-new-dtype": "string",
}
for in, want := range cases {
if got := dtypeToInternalType(in); got != want {
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
}
}
}
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
// offset: write some bytes, seek back to the middle, overwrite, end up with
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
func TestDataframe_BytesWriterSeeker(t *testing.T) {
t.Parallel()
var w bytesWriterSeeker
if _, err := w.Write([]byte("hello world")); err != nil {
t.Fatal(err)
}
if _, err := w.Seek(6, 0); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("WORLD")); err != nil {
t.Fatal(err)
}
if got := string(w.buf); got != "hello WORLD" {
t.Errorf("buf = %q, want \"hello WORLD\"", got)
}
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -50,20 +49,6 @@ type objectCRUDSpec struct {
// right nesting level.
enhanceCreateInput func(rt flagView, input map[string]interface{})
enhanceUpdateInput func(rt flagView, input map[string]interface{})
// validateCreateInput, when set, runs after enhanceCreateInput to
// enforce cross-flag / cross-field, create-only constraints JSON
// Schema can't express. Two uses today:
// - pivot rejects --target-position vs --range when both carry
// non-default values — they map to the same wire field and
// conflicting values are ambiguous (needs raw flags via rt).
// - cond-format requires every properties.attrs entry to match the
// sibling rule_type's shape (see validateCondFormatAttrs); a
// colorScale rule fed cellIs-shaped attrs writes a color-less
// segment that breaks the sheet on open (inspects input only).
// It is the create-path twin of validateUpdateInput; the same scope
// notes apply. Validators that only inspect the wire input can ignore
// the rt argument.
validateCreateInput func(rt flagView, input map[string]interface{}) error
// validateUpdateInput, when set, runs after enhanceUpdateInput to
// enforce *cross-field, update-only* constraints JSON Schema can't
// express (e.g. sparkline requires properties.sparklines[i] to
@@ -155,7 +140,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -205,11 +190,6 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
if spec.enhanceCreateInput != nil {
spec.enhanceCreateInput(runtime, input)
}
if spec.validateCreateInput != nil {
if err := spec.validateCreateInput(runtime, input); err != nil {
return nil, err
}
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
@@ -244,7 +224,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -328,7 +308,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -401,6 +381,9 @@ var pivotSpec = objectCRUDSpec{
},
createWarn: pivotPlacementWarn,
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
input["target_position"] = v
}
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
@@ -408,26 +391,10 @@ var pivotSpec = objectCRUDSpec{
if v := strings.TrimSpace(rt.Str("source")); v != "" {
props["source"] = v
}
// --target-position 与 --range 都映射到 properties.range
// --target-position 优先,未给(或为默认值 A1时回落到 --range。
// 互斥校验在 validateCreateInput 里做。
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
props["range"] = v
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
if v := strings.TrimSpace(rt.Str("range")); v != "" {
props["range"] = v
}
},
// --target-position 与 --range 落到同一 wire 字段properties.range
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
// 的处理方式CLI 端直接拒绝(优于静默丢弃其一)。
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
pos := strings.TrimSpace(rt.Str("target-position"))
rng := strings.TrimSpace(rt.Str("range"))
if pos != "" && pos != "A1" && rng != "" {
return common.ValidationErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
}
return nil
},
}
var PivotCreate = newObjectCreateShortcut(pivotSpec)
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
@@ -520,118 +487,7 @@ var condFormatSpec = objectCRUDSpec{
idField: "conditional_format_id",
enhanceCreateInput: condFormatEnhance,
enhanceUpdateInput: condFormatEnhance,
// validateCondFormatAttrs only inspects the wire input, so the create
// hook ignores rt; the update hook (func(input)) calls it directly.
validateCreateInput: func(_ flagView, input map[string]interface{}) error {
return validateCondFormatAttrs(input)
},
validateUpdateInput: validateCondFormatAttrs,
}
// condFormatAttrsRequired maps each conditional-format rule_type to the
// keys every properties.attrs entry must carry for that rule. It mirrors
// the per-rule attrs contract the tool's manage_conditional_format_object
// converter reads (byted-sheet ai-tools manage-conditional-format-object.ts):
// that converter maps each attrs entry *blindly by rule_type*, so a
// colorScale rule fed cellIs-shaped attrs ({compare_type,value}) silently
// yields a color-less color-scale segment — dirty data that crashes the
// frontend on snapshot deserialization (the 5005 "can't open" report this
// validator was added for).
//
// JSON Schema can't catch this: properties.attrs.items is a oneOf over all
// nine shapes, and the validator accepts an entry as soon as *any* branch
// matches — blind to the sibling rule_type. {compare_type,value} matches
// the cellIs branch regardless of whether rule_type says colorScale.
//
// Rule types absent from the map (duplicateValues, uniqueValues,
// containsBlanks, notContainsBlanks) carry no attrs, so nothing to check.
// Counts (dataBar==2, colorScale 23, iconSet ordering) stay the tool's
// job — it already rejects those with actionable messages; the gap this
// closes is per-entry *shape*, which the tool does not check.
var condFormatAttrsRequired = map[string][]string{
"cellIs": {"compare_type", "value"},
"containsText": {"compare_type", "text"},
"timePeriod": {"operator", "time_period"},
"dataBar": {"color", "value_type"},
"colorScale": {"value_type", "color"},
"rank": {"is_bottom", "value_type"},
"aboveAverage": {"operator"},
"expression": {"formula"},
"iconSet": {"icon_type", "value_type", "operator"},
}
// validateCondFormatAttrs enforces that every properties.attrs entry
// matches the shape required by the sibling properties.rule_type. Shared
// by create and update. On update, rule_type may be omitted (the caller is
// editing style only and the existing rule's type governs the attrs shape,
// which the CLI can't see); in that case validation is deferred to the
// server. Missing/empty attrs is likewise left to the tool, which already
// reports "attrs are required for rule_type: X" clearly.
func validateCondFormatAttrs(input map[string]interface{}) error {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return nil
}
ruleType, _ := props["rule_type"].(string)
ruleType = strings.TrimSpace(ruleType)
if ruleType == "" {
return nil
}
required, ok := condFormatAttrsRequired[ruleType]
if !ok {
return nil
}
attrs, ok := props["attrs"].([]interface{})
if !ok {
// Missing attrs, or a non-array shape the schema check already
// flagged — nothing for this cross-field rule to add.
return nil
}
for i, entryRaw := range attrs {
entry, ok := entryRaw.(map[string]interface{})
if !ok {
continue // schema validation owns per-entry type errors.
}
for _, key := range required {
if v, has := entry[key]; !has || condAttrIsBlank(v) {
return common.ValidationErrorf(
"--properties: attrs[%d] is missing %q, which rule_type %q requires on every entry (expected keys %s; got %s). "+
"A common cause is reusing another rule's attrs shape — e.g. cellIs-style {compare_type,value} under a colorScale rule, which writes a color-less segment that breaks the sheet on open.",
i, key, ruleType, strings.Join(required, "+"), condAttrPresentKeys(entry))
}
}
}
return nil
}
// condAttrIsBlank treats a present-but-empty string (after trimming) as
// missing. The crash-causing case is an empty `color`, but an empty value
// for any required key is never meaningful in these branches, so the rule
// is uniform. Non-string values (numbers, booleans) count as present.
func condAttrIsBlank(v interface{}) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
// condAttrPresentKeys lists the keys actually present on an attrs entry,
// sorted, for the "got ..." half of the error message.
func condAttrPresentKeys(entry map[string]interface{}) string {
if len(entry) == 0 {
return "{}"
}
keys := make([]string, 0, len(entry))
for k := range entry {
keys = append(keys, k)
}
sort.Strings(keys)
return "{" + strings.Join(keys, ",") + "}"
}
var CondFormatCreate = newObjectCreateShortcut(condFormatSpec)
var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec)
var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec)
@@ -876,7 +732,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -1026,7 +882,7 @@ var FilterCreate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -1101,7 +957,7 @@ var FilterUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -1169,7 +1025,7 @@ var FilterDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -4,12 +4,9 @@
package sheets
import (
"encoding/json"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -140,24 +137,25 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
// covered separately in the +pivot-create empty-selector / mutex
// tests below.
{
name: "+pivot-create with placement / source / target-position flags",
name: "+pivot-create with placement / source / range flags",
sc: PivotCreate,
args: []string{
"--url", testURL, "--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--range", "F1",
"--target-position", "B5",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"target_position": "B5",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
// --target-position 映射到 properties.range。
"range": "B5",
"range": "F1",
},
},
},
@@ -204,7 +202,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-id", "ruleA",
"--properties", `{"attrs":[{"compare_type":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
"--rule-type", "cellIs",
"--ranges", `["A1:A100"]`,
},
@@ -216,7 +214,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
"conditional_format_id": "ruleA",
"properties": map[string]interface{}{
"rule_type": "cellIs",
"attrs": []interface{}{map[string]interface{}{"compare_type": "greaterThan", "value": "100"}},
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
"style": map[string]interface{}{"back_color": "#FFD7D7"},
"ranges": []interface{}{"A1:A100"},
},
@@ -473,18 +471,24 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
t.Run("both set is rejected", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--target-sheet-name", "Sheet1",
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
ve := requireValidation(t, err, "mutually exclusive")
if err == nil {
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "mutually exclusive") {
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
}
// 错误信息必须用真实的 flag 名target-*),否则模型按消息提示去
// 改 --sheet-id 还是错的。
if !strings.Contains(ve.Message, "--target-sheet-id") {
t.Errorf("expected error to quote --target-sheet-id flag name; got message=%q", ve.Message)
if !strings.Contains(combined, "--target-sheet-id") {
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
}
})
@@ -503,49 +507,6 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
})
}
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
// and --range cannot both be set" guardrail on +pivot-create. They map to
// the same wire field (properties.range), so two non-default values are
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
// --target-sheet-name mutex). --target-position=A1 is the documented default
// and is treated as "not set" — pairing it with --range still works.
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
t.Parallel()
t.Run("both non-default values rejected", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "B5",
"--range", "F1",
})
ve := requireValidation(t, err, "mutually exclusive")
if !strings.Contains(ve.Message, "--target-position") || !strings.Contains(ve.Message, "--range") {
t.Errorf("expected error to quote both --target-position and --range; got message=%q", ve.Message)
}
})
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "A1",
"--range", "F1",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
props, _ := input["properties"].(map[string]interface{})
if got, _ := props["range"].(string); got != "F1" {
t.Errorf("properties.range = %q, want %q", got, "F1")
}
})
}
// TestPivotCreate_SchemaValidates exercises the schema-driven
// validator wired into objectCreateInput. The pivot create schema
// doesn't constrain rows/columns/values to be present (the backend
@@ -557,27 +518,35 @@ func TestPivotCreate_SchemaValidates(t *testing.T) {
t.Run("rejects wrong type for rows", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"rows":"not-an-array"}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
ve := requireValidation(t, err, "rows")
if !strings.Contains(ve.Message, "array") {
t.Errorf("expected error to mention array; got message=%q", ve.Message)
if err == nil {
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
}
})
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
requireValidation(t, err, "summarize_by")
if err == nil {
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
}
if !strings.Contains(stderr+err.Error(), "summarize_by") {
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
}
})
t.Run("schema-conformant input is accepted", func(t *testing.T) {
@@ -611,8 +580,14 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
requireValidation(t, err, "specify at least one of --sheet-id or --sheet-name")
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
}
})
}
}
@@ -623,184 +598,19 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
// +sparkline-list, before any server call goes out.
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
})
ve := requireValidation(t, err, "missing sparkline_id")
if !strings.Contains(ve.Message, "+sparkline-list") {
t.Errorf("expected error to point at +sparkline-list; got message=%q", ve.Message)
if err == nil {
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
}
}
// TestCondFormatAttrs_ShapeMatchesRuleType regresses the cross-field
// guard that rejects attrs whose shape doesn't match the sibling
// rule_type — the gap behind the "缺 color 的 colorScale 脏数据导致表格
// 打不开" report: a colorScale rule fed cellIs-shaped attrs
// ({compare_type,value}, no color) passed both the CLI's per-entry oneOf
// schema check and the tool, writing a color-less segment that crashed
// the frontend on open. The check covers create and update symmetrically.
func TestCondFormatAttrs_ShapeMatchesRuleType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
wantErr bool
wantMsg string // substring expected in the error, when wantErr
}{
{
name: "colorScale fed cellIs-shaped attrs (missing color) is rejected",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"},{"compare_type":"lessThan","value":"100"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: "colorScale",
},
{
name: "colorScale with empty color string is rejected",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":""},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: `"color"`,
},
{
name: "well-formed colorScale attrs pass",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":"#FFFFFF"},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
},
wantErr: false,
},
{
name: "update path is guarded too (colorScale + cellIs attrs)",
sc: CondFormatUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA",
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: "colorScale",
},
combined := stderr + err.Error()
if !strings.Contains(combined, "missing sparkline_id") {
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if tt.wantErr {
requireValidation(t, err, tt.wantMsg)
return
}
if err != nil {
t.Fatalf("expected acceptance (dry-run); got err=%v stderr=%s", err, stderr)
}
})
}
}
// TestCondFormatAttrsRequired_MatchesSchemaOneOf guards against drift
// between the hand-maintained condFormatAttrsRequired table (the source
// validateCondFormatAttrs enforces) and the embedded flag-schemas.json
// attrs oneOf (the authoritative shape contract synced from the spec
// repo). The cross-field validator only works if its per-rule_type
// required keys mirror the schema branches; if a future schema sync adds
// or drops a required key on any branch without updating the table, the
// CLI would silently under- or over-validate. They share no compile-time
// link, so this test is the only thing pinning them together.
//
// The schema oneOf branches are NOT labeled by rule_type (that's the whole
// point — rule_type is a sibling field the per-entry oneOf can't see), so
// we can't match branch→rule_type. We instead compare the *multiset* of
// required-key sets: every branch's required array must appear as some
// table entry's value and vice versa. This catches any added/dropped
// required key (real drift); it tolerates only a relabeling between two
// branches that happen to share an identical required set (dataBar and
// colorScale both require {color,value_type}), which is harmless here.
func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
t.Parallel()
// multiset key: required keys sorted + joined, so order within a
// branch's required array doesn't matter.
keyOf := func(req []string) string {
s := append([]string(nil), req...)
sort.Strings(s)
return strings.Join(s, "+")
}
tableMS := map[string]int{}
for _, req := range condFormatAttrsRequired {
tableMS[keyOf(req)]++
}
schemaMS := func(t *testing.T, command string) map[string]int {
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags[command]["properties"]
if !ok {
t.Fatalf("no embedded schema for %s --properties", command)
}
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal %s properties schema: %v", command, err)
}
dig := func(m map[string]interface{}, key string) map[string]interface{} {
next, _ := m[key].(map[string]interface{})
if next == nil {
t.Fatalf("%s: missing %q while navigating to attrs oneOf", command, key)
}
return next
}
attrs := dig(dig(schema, "properties"), "attrs")
items := dig(attrs, "items")
oneOf, ok := items["oneOf"].([]interface{})
if !ok || len(oneOf) == 0 {
t.Fatalf("%s: attrs.items.oneOf is missing or empty", command)
}
ms := map[string]int{}
for i, branchRaw := range oneOf {
branch, ok := branchRaw.(map[string]interface{})
if !ok {
t.Fatalf("%s: oneOf[%d] is not an object", command, i)
}
reqRaw, _ := branch["required"].([]interface{})
req := make([]string, 0, len(reqRaw))
for _, r := range reqRaw {
if s, ok := r.(string); ok {
req = append(req, s)
}
}
ms[keyOf(req)]++
}
return ms
}
for _, command := range []string{"+cond-format-create", "+cond-format-update"} {
got := schemaMS(t, command)
if len(got) != len(tableMS) {
t.Errorf("%s: schema oneOf has %d distinct required-sets, table has %d", command, len(got), len(tableMS))
}
for k, n := range tableMS {
if got[k] != n {
t.Errorf("%s: required-set %q appears %d× in schema but %d× in condFormatAttrsRequired — table drifted from schema; re-sync the table", command, k, got[k], n)
}
}
for k, n := range got {
if tableMS[k] != n {
t.Errorf("%s: schema branch with required-set %q (×%d) has no matching condFormatAttrsRequired entry — add it to the table", command, k, n)
}
}
if !strings.Contains(combined, "+sparkline-list") {
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
}
}
@@ -815,13 +625,18 @@ func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
// create still mandates one of --image / --image-token / --image-uri.
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--image-name", "x.png",
"--position-row", "0", "--position-col", "A",
"--size-width", "10", "--size-height", "10",
})
requireValidation(t, err, "one of --image, --image-token, or --image-uri is required")
if err == nil {
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
}
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
}
}
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
@@ -844,8 +659,14 @@ func TestObjectDelete_AllHighRisk(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
}
})
}
}

View File

@@ -57,7 +57,7 @@ func newObjectListShortcut(spec objectListSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -45,7 +45,7 @@ var CellsClear = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -163,7 +163,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -239,7 +239,7 @@ var RowsResize = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -279,7 +279,7 @@ var ColsResize = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -451,7 +451,7 @@ var RangeFill = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -490,7 +490,7 @@ var RangeSort = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -540,7 +540,7 @@ func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -287,11 +287,16 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, RangeSort, []string{
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
})
requireValidation(t, err, c.want)
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
}
})
}
}
@@ -344,8 +349,13 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
requireValidation(t, err, tt.want)
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}

View File

@@ -5,6 +5,8 @@ package sheets
import (
"context"
"encoding/csv"
"regexp"
"strconv"
"strings"
@@ -57,7 +59,7 @@ var CellsGet = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -150,7 +152,7 @@ var CsvGet = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -162,7 +164,12 @@ var CsvGet = common.Shortcut{
if err != nil {
return err
}
if !runtime.Bool("include-row-prefix") {
switch {
case runtime.Bool("rows-json"):
// --rows-json reshapes the CSV response into structured rows
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
case !runtime.Bool("include-row-prefix"):
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
@@ -212,6 +219,141 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
return m
}
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
// the tool prepends to the first physical line of each logical CSV record.
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
//
// {
// "range": "A1:K3380",
// "current_region": "...", // passthrough, if the tool returned it
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
// }
//
// Every logical row is emitted, including the first — no row is assumed to be a
// header, since sheet data is not always tabular. Each cell is keyed by its
// column letter (from the tool's col_indices when present, else derived from the
// requested range's start column). On any parsing trouble it returns the
// original output unchanged.
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
m, ok := out.(map[string]interface{})
if !ok {
return out
}
csvStr, ok := m["annotated_csv"].(string)
if !ok {
return out
}
// Group physical lines into logical records by [row=N] boundaries; lines
// without a prefix are embedded-newline continuations of the current record.
type logicalRow struct {
num int
text string
}
var groups []logicalRow
for _, line := range strings.Split(csvStr, "\n") {
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
n, _ := strconv.Atoi(mm[1])
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
} else if len(groups) > 0 {
groups[len(groups)-1].text += "\n" + line
}
}
if len(groups) == 0 {
return out
}
// Parse every logical row; widest row sets the column count. No row is
// singled out as a header — that would assume the data is tabular, which it
// often is not. The model reads row 1 like any other row and decides for
// itself whether it is a header.
parsed := make([][]string, len(groups))
maxCols := 0
for i, g := range groups {
parsed[i] = parseCSVRecord(g.text)
if len(parsed[i]) > maxCols {
maxCols = len(parsed[i])
}
}
if maxCols == 0 {
return out
}
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
// length == col_count); otherwise derive from the requested range's start col.
letters := coerceStringSlice(m["col_indices"])
if len(letters) < maxCols {
start := csvStartColIndex(requestedRange)
letters = make([]string, maxCols)
for j := 0; j < maxCols; j++ {
letters[j] = csvColLetter(start + j)
}
}
rows := make([]map[string]interface{}, 0, len(groups))
for i := range groups {
fields := parsed[i]
values := make(map[string]interface{}, len(letters))
for j := range letters {
v := ""
if j < len(fields) {
v = fields[j]
}
values[letters[j]] = v
}
rows = append(rows, map[string]interface{}{
"row_number": groups[i].num,
"values": values,
})
}
result := map[string]interface{}{}
for k, v := range m {
result[k] = v
}
result["range"] = requestedRange
result["rows"] = rows
// Surface the backend's "数据没读全" signal structurally instead of leaving it
// buried in warning_message prose. The tool flags it when current_region (the
// true data extent) reaches past actual_range (what was actually read) — the
// single most important anti-under-read hint. Mirror that same comparison
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
// model gets the real data range as a first-class field, never having to
// parse it out of prose.
if cr, _ := m["current_region"].(string); cr != "" {
ar, _ := m["actual_range"].(string)
regionEnd := a1EndRow(cr)
readEnd := a1EndRow(ar)
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
result["data_not_fully_read"] = map[string]interface{}{
"read_through_row": readEnd,
"data_extends_through_row": regionEnd,
"unread_rows": regionEnd - readEnd,
"reread_range": cr,
}
}
}
// Drop the fields whose information rows-json fully carries elsewhere:
// - annotated_csv / row_indices / col_indices → reconstructed into
// columns + rows (with integer row_number), losslessly.
// - warning_message → its two halves are both handled: the static
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
// and the dynamic "数据没读全" half is now the structured
// data_not_fully_read field above. (Confirmed against the backend's
// get-range-as-csv.ts — warning_message has no other content.)
delete(result, "annotated_csv")
delete(result, "row_indices")
delete(result, "col_indices")
delete(result, "warning_message")
return result
}
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
func a1EndRow(rng string) int {
@@ -235,6 +377,89 @@ func a1EndRow(rng string) int {
return n
}
// parseCSVRecord parses a single logical CSV record (which may span multiple
// physical lines via quoted embedded newlines) into its fields. An empty record
// yields no fields; a malformed record falls back to a naive comma split so a
// stray quote never drops a whole row.
func parseCSVRecord(text string) []string {
if strings.TrimSpace(text) == "" {
return nil
}
r := csv.NewReader(strings.NewReader(text))
r.FieldsPerRecord = -1
fields, err := r.Read()
if err != nil {
return strings.Split(text, ",")
}
return fields
}
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
// of strings (the shape of the tool's col_indices), else nil.
func coerceStringSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return nil
}
out = append(out, s)
}
return out
}
// csvStartColIndex returns the 0-based column index of a range's start column,
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
func csvStartColIndex(rng string) int {
rng = strings.TrimSpace(rng)
if i := strings.LastIndex(rng, "!"); i >= 0 {
rng = rng[i+1:]
}
var letters strings.Builder
for _, c := range rng {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
letters.WriteRune(c)
continue
}
break
}
if letters.Len() == 0 {
return 0
}
return csvColToIndex(letters.String())
}
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
// "AA"→26). Non-letter input → -1.
func csvColToIndex(s string) int {
n := 0
for _, c := range strings.ToUpper(s) {
if c < 'A' || c > 'Z' {
break
}
n = n*26 + int(c-'A'+1)
}
return n - 1
}
// csvColLetter converts a 0-based column index back to its letter (0→"A",
// 25→"Z", 26→"AA"). Negative input → "".
func csvColLetter(idx int) string {
if idx < 0 {
return ""
}
var b []byte
for idx >= 0 {
b = append([]byte{byte('A' + idx%26)}, b...)
idx = idx/26 - 1
}
return string(b)
}
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
// dropdown configuration on a range. Aligned with its sibling +cells-get
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
@@ -269,7 +494,7 @@ var DropdownGet = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -63,6 +63,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"value_render_option": "formatted_value",
},
},
{
// --rows-json is post-processing on +csv-get's response; it must
// NOT leak into the get_range_as_csv input.
name: "+csv-get --rows-json builds the same input (flag is post-process)",
sc: CsvGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
toolName: "get_range_as_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:C10",
"max_rows": float64(unboundedReadLimit),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -81,12 +95,15 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
// every other get_cell_ranges wrapper uses.
func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownGet, []string{
stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{
"--url", testURL, "--range", "A2:A100", "--dry-run",
})
ve := requireValidation(t, err, "")
if !strings.Contains(ve.Message, "sheet-id") && !strings.Contains(ve.Message, "sheet-name") {
t.Errorf("expected --sheet-id/--sheet-name guard; got message=%q", ve.Message)
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") {
t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err)
}
}
@@ -106,10 +123,15 @@ func TestReadData_RequiresRange(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, c.sc, []string{
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run",
})
requireValidation(t, err, "--range is required")
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "--range is required") {
t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err)
}
})
}
}
@@ -157,3 +179,113 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
t.Errorf("other field corrupted: %v", out["other"])
}
}
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
// emitted (no header singled out), integer row_number, column-letter keyed
// values, embedded newlines inside quoted fields, and current_region passthrough.
func TestAssembleRowsJSON(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
"current_region": "A1:C3",
"col_indices": []interface{}{"A", "B", "C"},
"row_indices": []interface{}{1, 2, 3},
"warning_message": "①定位行号…②定位列字母…",
}
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
if !ok {
t.Fatalf("assembleRowsJSON did not return a map")
}
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
// indices → rows; warning_message → moot static nag + structured
// data_not_fully_read). Unrelated metadata like current_region is preserved.
if _, exists := out["annotated_csv"]; exists {
t.Errorf("annotated_csv should be dropped")
}
if _, exists := out["col_indices"]; exists {
t.Errorf("col_indices should be dropped")
}
if _, exists := out["warning_message"]; exists {
t.Errorf("warning_message should be dropped in rows-json mode")
}
if _, exists := out["columns"]; exists {
t.Errorf("columns field should not exist (no header assumption)")
}
if out["current_region"] != "A1:C3" {
t.Errorf("current_region passthrough lost: %v", out["current_region"])
}
rows, _ := out["rows"].([]map[string]interface{})
if len(rows) != 3 {
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
}
// Row 1 is emitted as a normal row, not consumed as a header.
if rows[0]["row_number"].(int) != 1 {
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
t.Errorf("row 1 values wrong: %+v", v)
}
// Row 2 keeps its embedded newline inside a single cell.
v1 := rows[1]["values"].(map[string]interface{})
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
}
}
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
// range start when the tool omits col_indices (e.g. a C-anchored read).
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
}
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
rows := out["rows"].([]map[string]interface{})
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d", len(rows))
}
if rows[0]["row_number"].(int) != 5 {
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
t.Errorf("derived-letter values wrong: %+v", v)
}
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
t.Errorf("row 6 values wrong: %+v", v)
}
}
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
// when current_region extends past actual_range, rows-json surfaces the true data
// range as a first-class field (mirroring the backend's prose warning).
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
t.Parallel()
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
in := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D4",
}
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
hint, ok := out["data_not_fully_read"].(map[string]interface{})
if !ok {
t.Fatalf("data_not_fully_read missing; out=%+v", out)
}
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
t.Errorf("data_not_fully_read wrong: %+v", hint)
}
// Fully-read case: no hint emitted.
in2 := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D2",
}
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
if _, exists := out2["data_not_fully_read"]; exists {
t.Errorf("data_not_fully_read should be absent when fully read")
}
}

View File

@@ -46,7 +46,7 @@ var CellsSearch = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -122,7 +122,7 @@ var CellsReplace = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -89,17 +89,14 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
func TestCellsReplace_RequireFlag(t *testing.T) {
t.Parallel()
// --replace not passed at all (vs empty string) should error. This trips
// cobra's required-flag gate before our Validate hook runs, so the error
// is cobra's plain `required flag(s) "replacement" not set` rather than a
// typed *errs.ValidationError — keep this assertion as a substring check.
// --replace not passed at all (vs empty string) should error.
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
})
if err == nil {
t.Fatalf("expected error when --replacement omitted; stdout=%s stderr=%s", stdout, stderr)
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(err.Error(), "replacement") {
t.Errorf("expected message about --replacement; got=%s|%s|%v", stdout, stderr, err)
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
}
}

View File

@@ -51,7 +51,7 @@ var SheetInfo = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -136,7 +136,7 @@ var DimInsert = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -211,7 +211,7 @@ var DimDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -300,7 +300,7 @@ var DimFreeze = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -395,7 +395,7 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -439,7 +439,7 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -593,7 +593,7 @@ var DimMove = common.Shortcut{
Set("spreadsheet_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -198,8 +198,13 @@ func TestDimRange_Validation(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DimHide, tt.args)
requireValidation(t, err, tt.want)
stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args)
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
@@ -264,11 +269,16 @@ func TestDimMove_Column(t *testing.T) {
// column (or vice versa) is rejected at Validate.
func TestDimMove_MismatchedDimension(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DimMove, []string{
stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "1:3", "--target", "H", "--dry-run",
})
requireValidation(t, err, "must match --source-range")
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") {
t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestParseA1Range covers parser edge cases directly.

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestNormalizeCellStyleAliases pins the shorthand → canonical renaming for a
// single cell_styles map: the alignment shorthands models commonly hallucinate
// are rewritten in place, values are preserved, and a shorthand colliding with
// its canonical key is a hard error rather than a silent pick.
func TestNormalizeCellStyleAliases(t *testing.T) {
t.Parallel()
t.Run("renames *_align shorthands, keeps values and other fields", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{
"horizontal_align": "center",
"vertical_align": "middle",
"font_weight": "bold",
}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if style["horizontal_alignment"] != "center" || style["vertical_alignment"] != "middle" {
t.Errorf("alignment not renamed: %#v", style)
}
if _, ok := style["horizontal_align"]; ok {
t.Errorf("shorthand horizontal_align should be removed: %#v", style)
}
if _, ok := style["vertical_align"]; ok {
t.Errorf("shorthand vertical_align should be removed: %#v", style)
}
if style["font_weight"] != "bold" {
t.Errorf("unrelated field font_weight dropped: %#v", style)
}
})
t.Run("renames halign/valign shorthands", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{"halign": "left", "valign": "top"}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if style["horizontal_alignment"] != "left" || style["vertical_alignment"] != "top" {
t.Errorf("halign/valign not renamed: %#v", style)
}
})
t.Run("shorthand colliding with canonical is an error", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{
"horizontal_align": "center",
"horizontal_alignment": "left",
}
err := normalizeCellStyleAliases(style, "cell_styles[0]")
requireValidation(t, err, "conflicts with horizontal_alignment")
})
t.Run("no shorthand leaves the map untouched", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{"font_weight": "bold", "horizontal_alignment": "center"}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(style) != 2 || style["font_weight"] != "bold" || style["horizontal_alignment"] != "center" {
t.Errorf("map should be unchanged: %#v", style)
}
})
t.Run("empty map is a no-op", func(t *testing.T) {
t.Parallel()
if err := normalizeCellStyleAliases(map[string]interface{}{}, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
// TestNormalizeTypedCellsStyleAliases pins the 2D --cells walk: every cell's
// inline cell_styles is normalized, malformed shapes are skipped (matching the
// pass-through contract) rather than rejected, and a conflict propagates.
func TestNormalizeTypedCellsStyleAliases(t *testing.T) {
t.Parallel()
t.Run("normalizes inline cell_styles across the grid", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
[]interface{}{
map[string]interface{}{
"value": "x",
"cell_styles": map[string]interface{}{"horizontal_align": "center"},
},
map[string]interface{}{"value": "y"}, // no cell_styles → untouched
},
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
row := cells[0].([]interface{})
st := row[0].(map[string]interface{})["cell_styles"].(map[string]interface{})
if st["horizontal_alignment"] != "center" {
t.Errorf("cell_styles not normalized: %#v", st)
}
if _, ok := st["horizontal_align"]; ok {
t.Errorf("shorthand should be removed: %#v", st)
}
})
t.Run("malformed shapes are skipped, not rejected", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
"not-a-row",
[]interface{}{
"not-a-cell",
map[string]interface{}{"cell_styles": "not-a-map"},
},
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
t.Fatalf("lenient walk should not error on odd shapes: %v", err)
}
})
t.Run("conflict inside a cell propagates", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
[]interface{}{
map[string]interface{}{
"cell_styles": map[string]interface{}{
"valign": "top",
"vertical_alignment": "middle",
},
},
},
}
err := normalizeTypedCellsStyleAliases(cells, "--cells")
requireValidation(t, err, "--cells[0][0].cell_styles")
})
}
// TestCellsSet_StyleAliasesNormalized is the end-to-end guard for +cells-set:
// a typed --cells payload using alignment shorthands reaches set_cell_range
// with canonical field names so the backend doesn't silently drop them.
func TestCellsSet_StyleAliasesNormalized(t *testing.T) {
t.Parallel()
cells := `[[{"value":"Header","cell_styles":{"horizontal_align":"center","vertical_align":"middle","font_weight":"bold"}}]]`
body := parseDryRunBody(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells,
})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
s := string(raw)
if !strings.Contains(s, `"horizontal_alignment":"center"`) || !strings.Contains(s, `"vertical_alignment":"middle"`) {
t.Errorf("alignment shorthands not normalized in cells: %s", s)
}
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
t.Errorf("shorthand keys leaked through to backend payload: %s", s)
}
}
// TestWorkbookCreate_StyleAliasesNormalized is the end-to-end guard for
// +workbook-create --styles: alignment shorthands in a cell_styles op are
// accepted (no "unsupported style field" error) and emitted as canonical
// field names merged into the fill cells.
func TestWorkbookCreate_StyleAliasesNormalized(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_align":"center","vertical_align":"middle"}]}]}`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
}
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
s := string(raw)
if c := strings.Count(s, `"horizontal_alignment":"center"`); c != 4 {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", c, s)
}
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
t.Errorf("shorthand keys leaked through after normalization: %s", s)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
// --output-path, +workbook-export delegates to the shared drive export core
// with OutputDir="" so it creates + polls the export task and returns the ready
// file token without writing a local file (downloaded=false).
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_export"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"job_status": float64(0),
"file_token": "ftk_xlsx",
"file_name": "report.xlsx",
"file_size": float64(2048),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
}, stubs...)
if err != nil {
t.Fatalf("export-only execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if env.Data["ready"] != true {
t.Errorf("ready = %v, want true", env.Data["ready"])
}
if env.Data["downloaded"] != false {
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
}
if env.Data["file_token"] != "ftk_xlsx" {
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
}
if env.Data["doc_type"] != "sheet" {
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
}
}

View File

@@ -1,133 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// chdirTemp switches into a fresh temp dir for the duration of the test and
// restores the original cwd afterwards. +workbook-import is the first sheets
// shortcut that stat()s a real local file, so these tests need a working dir.
func chdirTemp(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
}
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
// shared drive import core and hard-codes the import target type to "sheet".
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
var createBody map[string]interface{}
for _, c := range calls {
cm, _ := c.(map[string]interface{})
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
createBody, _ = cm["body"].(map[string]interface{})
}
}
if createBody == nil {
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
}
if createBody["type"] != "sheet" {
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
}
if createBody["file_extension"] != "xlsx" {
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
}
}
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
// front and the error surfaces through the normal envelope/err path.
_, _, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
requireValidation(t, err, "can only be imported")
}
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
// flow against stubs and asserts the resulting URL is a /sheets/ link.
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
},
{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_sheet"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"token": "shtcn_imported",
"type": "sheet",
"job_status": float64(0),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
if err != nil {
t.Fatalf("import execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("execute output has no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
t.Errorf("imported url = %q, want a /sheets/ link", url)
}
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
t.Errorf("token = %q, want shtcn_imported", tok)
}
}

View File

@@ -4,10 +4,13 @@
package sheets
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -142,28 +145,6 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
"tab_color": "",
},
},
{
name: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "show_gridline",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide_gridline",
"sheet_id": testSheetID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -228,8 +209,14 @@ func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) {
// high-risk-write — exit code 10 (confirmation_required) without --yes.
func TestSheetDelete_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID})
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
stdout, stderr, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID})
if err == nil {
t.Fatalf("expected confirmation_required error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
t.Errorf("expected confirmation envelope; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestWorkbook_Validation covers a few critical validation paths shared
@@ -243,11 +230,6 @@ func TestWorkbook_Validation(t *testing.T) {
sc common.Shortcut
args []string
wantMsg string
// cobraNative=true means the error originates from cobra's native
// flag parsing (e.g. required-flag enforcement) which is not wrapped
// into a typed errs.ValidationError, so the test falls back to a
// substring match on err.Error().
cobraNative bool
}{
{
name: "+workbook-info needs --url or --spreadsheet-token",
@@ -256,11 +238,10 @@ func TestWorkbook_Validation(t *testing.T) {
wantMsg: "at least one of --url or --spreadsheet-token",
},
{
name: "+workbook-info rejects both url and token",
sc: WorkbookInfo,
args: []string{"--url", testURL, "--spreadsheet-token", testToken},
wantMsg: "mutually exclusive",
cobraNative: true,
name: "+workbook-info rejects both url and token",
sc: WorkbookInfo,
args: []string{"--url", testURL, "--spreadsheet-token", testToken},
wantMsg: "mutually exclusive",
},
{
name: "+sheet-delete needs sheet selector",
@@ -269,11 +250,10 @@ func TestWorkbook_Validation(t *testing.T) {
wantMsg: "at least one of --sheet-id or --sheet-name",
},
{
name: "+sheet-create requires --title",
sc: SheetCreate,
args: []string{"--url", testURL},
wantMsg: "required flag(s) \"title\" not set",
cobraNative: true,
name: "+sheet-create requires --title",
sc: SheetCreate,
args: []string{"--url", testURL},
wantMsg: "required flag(s) \"title\" not set",
},
{
name: "+sheet-create row-count over cap",
@@ -285,14 +265,14 @@ func TestWorkbook_Validation(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if tt.cobraNative {
if err == nil || !strings.Contains(err.Error(), tt.wantMsg) {
t.Errorf("error message missing %q; got=%v", tt.wantMsg, err)
}
return
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, tt.wantMsg) {
t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined)
}
requireValidation(t, err, tt.wantMsg)
})
}
}
@@ -308,7 +288,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
}
c := calls[0].(map[string]interface{})
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
@@ -320,11 +300,12 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
}
})
t.Run("with values → 2-step plan", func(t *testing.T) {
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
"--headers", `["Name","Score"]`,
"--values", `[["alice",95],["bob",88]]`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
@@ -336,138 +317,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
body, _ := fill["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:B3" {
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
}
})
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
}
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 2 {
t.Fatalf("cells rows = %#v, want 2", input["cells"])
}
headerRow, _ := cells[0].([]interface{})
firstHeader, _ := headerRow[0].(map[string]interface{})
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
t.Errorf("first header style = %#v, want bold + background", firstStyle)
}
secondHeader, _ := headerRow[1].(map[string]interface{})
if secondHeader["border_styles"] == nil {
t.Errorf("second header missing border_styles: %#v", secondHeader)
}
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
if secondStyle["number_format"] != "0" {
t.Errorf("second header number_format = %#v, want 0", secondStyle)
}
dataRow, _ := cells[1].([]interface{})
firstData, _ := dataRow[0].(map[string]interface{})
if _, ok := firstData["cell_styles"]; ok {
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
}
secondData, _ := dataRow[1].(map[string]interface{})
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
if secondDataStyle["font_color"] != "#0f7b0f" {
t.Errorf("second data style = %#v, want font color", secondDataStyle)
}
})
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
}
})
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
t.Parallel()
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
// payload with only cell_merges would compute extent 0; Execute then
// skipped writeTypedSheets entirely and the visual ops were silently
// dropped. The dry-run plan must include the create + fill + merge_cells.
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--styles", `{"styles":[{"name":"Sheet1","cell_merges":[{"range":"A1:B1"}]}]}`,
})
if len(calls) < 3 {
t.Fatalf("api calls = %d, want >=3 (create + fill + merge_cells); calls=%#v", len(calls), calls)
}
// Walk every body and look for the merge_cells tool name in the input JSON.
sawMerge := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if toolName, _ := body["tool_name"].(string); toolName == "merge_cells" {
sawMerge = true
break
}
}
if !sawMerge {
t.Errorf("merge_cells tool call missing from dry-run plan; calls=%#v", calls)
}
})
t.Run("style-only payload (col_sizes) still fills and emits resize_range", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:C","type":"pixel","size":120}]}]}`,
})
sawResize := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if toolName, _ := body["tool_name"].(string); toolName == "resize_range" {
sawResize = true
break
}
}
if !sawResize {
t.Errorf("resize_range tool call missing from dry-run plan; calls=%#v", calls)
}
})
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--values", `[["a","b"]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
b1, _ := row0[1].(map[string]interface{})
b1s, _ := b1["cell_styles"].(map[string]interface{})
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
}
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
a1, _ := row0[0].(map[string]interface{})
a1s, _ := a1["cell_styles"].(map[string]interface{})
if a1s["font_color"] != nil {
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
}
})
}
@@ -480,44 +330,35 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
args []string
want string
}{
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
{"--values trailing JSON rejected", []string{"--title", "X", "--values", `[["a"]] trailing`}, "trailing data after JSON value"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run"))
requireValidation(t, err, tt.want)
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run"))
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
// shared drive export core: a single create-task POST (poll + download are
// described inline rather than as separate api entries).
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
// --output-path. The order should be: POST → GET (poll) → optional GET
// (download).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("url = %v", create["url"])
t.Errorf("first url = %v", create["url"])
}
body, _ := create["body"].(map[string]interface{})
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
@@ -525,30 +366,122 @@ func TestWorkbookExport_DryRun(t *testing.T) {
}
})
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
"--output-path", "/tmp/out.csv",
})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
if len(calls) != 3 {
t.Fatalf("api calls = %d, want 3", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
if body["sub_id"] != "sh1" {
t.Errorf("csv export missing sub_id: %#v", body)
}
dl := calls[2].(map[string]interface{})
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
t.Errorf("download url = %v", dl["url"])
}
})
t.Run("csv requires --sheet-id", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, WorkbookExport, []string{
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--dry-run",
})
requireValidation(t, err, "--sheet-id is required")
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") {
t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err)
}
})
}
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
t.Parallel()
t.Run("preserves typed request errors", func(t *testing.T) {
t.Parallel()
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
got := sheetsDownloadRequestError(in)
if got != in {
t.Fatalf("typed error was not preserved: got %T %v", got, got)
}
})
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
t.Parallel()
got := sheetsDownloadRequestError(errors.New("dial refused"))
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
})
tests := []struct {
name string
status int
wantCategory errs.Category
wantSubtype errs.Subtype
wantRetryable bool
}{
{
name: "5xx is retryable network server error",
status: http.StatusBadGateway,
wantCategory: errs.CategoryNetwork,
wantSubtype: errs.SubtypeNetworkServer,
wantRetryable: true,
},
{
name: "404 is API not found",
status: http.StatusNotFound,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeNotFound,
},
{
name: "429 is retryable API rate limit",
status: http.StatusTooManyRequests,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeRateLimit,
wantRetryable: true,
},
{
name: "other 4xx is API unknown",
status: http.StatusForbidden,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
StatusCode: tt.status,
RawBody: []byte("body"),
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
})
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
}
if p.Code != tt.status {
t.Fatalf("code = %d, want %d", p.Code, tt.status)
}
if p.LogID != "log123" {
t.Fatalf("log_id = %q, want log123", p.LogID)
}
if p.Retryable != tt.wantRetryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
}
})
}
}
// assertInputEquals compares the decoded tool input map against the wanted
// fields. Extra fields in `got` are allowed (defaults, optional fields);
// every key in `want` must match exactly.

View File

@@ -56,7 +56,7 @@ var CellsSet = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -88,9 +88,6 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
if err != nil {
return nil, err
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
@@ -132,7 +129,7 @@ var CellsSetStyle = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -200,12 +197,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
return input, nil
}
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
// plain values. Use +cells-set for anything richer (formula / style / note).
var CsvPut = common.Shortcut{
Service: "sheets",
Command: "+csv-put",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -240,7 +237,7 @@ var CsvPut = common.Shortcut{
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -417,7 +414,7 @@ var DropdownSet = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
@@ -804,7 +801,7 @@ var CellsSetImage = common.Shortcut{
Body(setCellBody)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}

View File

@@ -4,7 +4,6 @@
package sheets
import (
"fmt"
"strings"
"testing"
@@ -242,16 +241,18 @@ func TestDropdownSet_HighlightTriState(t *testing.T) {
// cycles the rest through a built-in palette).
func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--dry-run",
})
ve := requireValidation(t, err, "must not exceed dropdown source size")
if ve.Param != "--colors" {
t.Errorf("param = %q, want --colors", ve.Param)
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
@@ -317,7 +318,7 @@ func TestDropdownSet_ListFromRange(t *testing.T) {
// must be refused).
func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--source-range", "Sheet1!T1:T3",
@@ -325,9 +326,11 @@ func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
"--highlight",
"--dry-run",
})
ve := requireValidation(t, err, "must not exceed dropdown source size")
if ve.Param != "--colors" {
t.Errorf("param = %q, want --colors", ve.Param)
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
@@ -335,26 +338,36 @@ func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
// --source-range.
func TestDropdownSet_XorBothSet(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--options", `["a","b"]`,
"--source-range", "Sheet1!T1:T3",
"--dry-run",
})
requireValidation(t, err, "mutually exclusive")
if err == nil {
t.Fatal("expected XOR error, got nil")
}
if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_XorNeitherSet rejects passing neither --options nor
// --source-range.
func TestDropdownSet_XorNeitherSet(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--dry-run",
})
requireValidation(t, err, "one of --options")
if err == nil {
t.Fatal("expected required-one error, got nil")
}
if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") {
t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags +
@@ -387,60 +400,30 @@ func TestCellsSetStyle_FlatFlags(t *testing.T) {
func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetStyle, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetStyle, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--dry-run",
})
requireValidation(t, err, "at least one style flag")
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "at least one style flag") {
t.Errorf("expected style-flag guard; got=%s|%s|%v", stdout, stderr, err)
}
}
func TestCellsSet_RequiresJSONArray(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
// Schema validator fires first now: "--cells: expected type \"array\", got \"object\"".
// Either the schema phrasing or the legacy requireJSONArray phrasing is
// acceptable — both pin the same contract.
ve := requireValidation(t, err, "")
if !strings.Contains(ve.Message, `expected type "array"`) && !strings.Contains(ve.Message, "must be a JSON array") {
t.Errorf("expected array-type guard; got message=%q", ve.Message)
}
}
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
// shared folder) is rejected by the schema validator at flag-parse time,
// before it can reach the server and blow up pb serialization
// ("mentionFileInfo.fileType: enum value expected").
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
t.Parallel()
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
ve := requireValidation(t, err, "mention_type")
if !strings.Contains(ve.Message, "not in enum") {
t.Errorf("expected enum guard; got message=%q", ve.Message)
}
}
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
t.Parallel()
for _, mt := range []int{0, 22} {
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
if err != nil {
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, `expected type "array"`) && !strings.Contains(combined, "must be a JSON array") {
t.Errorf("expected array-type guard; got=%s|%s|%v", stdout, stderr, err)
}
}
@@ -498,13 +481,12 @@ func TestCellsSetImage_DryRun(t *testing.T) {
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--image", "./foo.png", "--dry-run",
})
ve := requireValidation(t, err, "must be exactly one cell")
if ve.Param != "--range" {
t.Errorf("param = %q, want --range", ve.Param)
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") {
t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err)
}
}
@@ -513,13 +495,12 @@ func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
// same way as a real run instead of printing a misleading success preview.
func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--image", "/etc/hosts", "--dry-run",
})
ve := requireValidation(t, err, "must be a relative path")
if ve.Param != "--image" {
t.Errorf("param = %q, want --image", ve.Param)
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a relative path") {
t.Errorf("expected unsafe-path guard during dry-run; got=%s|%s|%v", stdout, stderr, err)
}
}

View File

@@ -3,11 +3,7 @@
package sheets
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all lark-sheets shortcuts. The list is grouped by
// canonical skill to mirror the sheet-skill-spec layout
@@ -26,46 +22,10 @@ func Shortcuts() []common.Shortcut {
if _, ok := commandsWithSchema[all[i].Command]; ok {
all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command)
}
// Accept --token as a parse-time alias for --spreadsheet-token (the
// single highest-frequency reflex misspelling in eval traces) on every
// shortcut that registers --spreadsheet-token, so the typo costs zero
// round-trips instead of an unknown-flag failure. Wired through the
// existing PostMount hook and composed onto any prior PostMount, so the
// common framework needs no change at all.
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
}
return all
}
func hasFlag(flags []common.Flag, name string) bool {
for _, fl := range flags {
if fl.Name == name {
return true
}
}
return false
}
// withTokenAlias wraps an optional PostMount so that, after it runs, --token
// resolves to --spreadsheet-token at parse time via pflag's normalize hook (no
// duplicate flag in --help). It preserves any pre-existing PostMount — e.g.
// +csv-put's --range / --start-cell flag-group setup — by running it first.
func withTokenAlias(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
if name == "token" {
return pflag.NormalizedName("spreadsheet-token")
}
return pflag.NormalizedName(name)
})
}
}
func shortcutList() []common.Shortcut {
return []common.Shortcut{
// lark_sheet_workbook
@@ -78,11 +38,8 @@ func shortcutList() []common.Shortcut {
SheetHide,
SheetUnhide,
SheetSetTabColor,
SheetShowGridline,
SheetHideGridline,
WorkbookCreate,
WorkbookExport,
WorkbookImport,
// lark_sheet_sheet_structure
SheetInfo,
@@ -99,7 +56,6 @@ func shortcutList() []common.Shortcut {
CellsGet,
CsvGet,
DropdownGet,
TableGet,
// lark_sheet_search_replace
CellsSearch,
@@ -111,7 +67,6 @@ func shortcutList() []common.Shortcut {
CellsSetImage,
CsvPut,
DropdownSet,
TablePut,
// lark_sheet_range_operations
CellsClear,

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/spf13/cobra"
)
// TestWithTokenAlias verifies the PostMount-based --token → --spreadsheet-token
// alias: it resolves at parse time, and it composes onto (rather than replaces)
// any pre-existing PostMount — the property that lets it coexist with
// +csv-put's --range/--start-cell flag-group setup.
func TestWithTokenAlias(t *testing.T) {
t.Parallel()
// Alias resolves to the canonical flag.
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("spreadsheet-token", "", "")
withTokenAlias(nil)(cmd)
if err := cmd.Flags().Parse([]string{"--token", "shtABC"}); err != nil {
t.Fatalf("--token should resolve as an alias: %v", err)
}
if got := cmd.Flags().Lookup("spreadsheet-token").Value.String(); got != "shtABC" {
t.Errorf("--token should set --spreadsheet-token; got %q", got)
}
// Composes with an existing PostMount instead of dropping it.
prevRan := false
cmd2 := &cobra.Command{Use: "y"}
cmd2.Flags().String("spreadsheet-token", "", "")
withTokenAlias(func(_ *cobra.Command) { prevRan = true })(cmd2)
if !prevRan {
t.Error("pre-existing PostMount should still run")
}
if err := cmd2.Flags().Parse([]string{"--token", "shtZ"}); err != nil {
t.Fatalf("--token should still resolve when composed: %v", err)
}
}
// TestShortcuts_TokenAliasOnSpreadsheetTokenCommands asserts every shortcut that
// takes --spreadsheet-token ends up with a PostMount (the composed token alias),
// so the reflex typo is forgiven wherever the canonical flag exists.
func TestShortcuts_TokenAliasOnSpreadsheetTokenCommands(t *testing.T) {
t.Parallel()
for _, s := range Shortcuts() {
if hasFlag(s.Flags, "spreadsheet-token") && s.PostMount == nil {
t.Errorf("%s takes --spreadsheet-token but has no PostMount (token alias missing)", s.Command)
}
}
}

View File

@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
}
}

View File

@@ -0,0 +1,413 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
// It deliberately creates the new page before deleting the old one so a create
// failure cannot remove existing user content. The operation is not atomic.
const replacePagesInitialRevisionID = -1
var SlidesReplacePages = common.Shortcut{
Service: "slides",
Command: "+replace-pages",
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
return err
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return err
}
return validateReplacePagesInput(pages)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI()
resolved, err := prepareReplacePages(runtime)
if err != nil {
return dry.Set("error", err.Error())
}
appendReplacePagesDryRunCalls(dry, resolved)
return dry.
Set("xml_presentation_id", resolved.PresentationID).
Set("pages_count", len(resolved.Plan)).
Set("plan", replacePagesPlanOutput(resolved.Plan)).
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
resolved, err := prepareReplacePages(runtime)
if err != nil {
return err
}
if runtime.Bool("validate-only") {
runtime.Out(map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"plan": replacePagesPlanOutput(resolved.Plan),
"status": "validated",
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
}, nil)
return nil
}
revisionID := replacePagesInitialRevisionID
results := make([]replacePageResult, 0, len(resolved.Plan))
for i, item := range resolved.Plan {
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
results = append(results, result)
if result.RevisionID != nil {
revisionID = *result.RevisionID
}
if err != nil {
if runtime.Bool("continue-on-error") {
continue
}
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
}
}
out := map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"results": replacePageResultsOutput(results),
"status": "completed",
"summary": replacePagesSummaryOutput(results),
"note": "batch replace is not atomic; each page was created before its old page was deleted",
}
if revisionID != replacePagesInitialRevisionID {
out["revision_id"] = revisionID
}
if hasReplacePageFailures(results) {
out["status"] = "partial_failure"
return runtime.OutPartialFailure(out, nil)
}
runtime.Out(out, nil)
return nil
},
}
type replacePageInput struct {
SlideID string
Content string
}
type replacePagePlanItem struct {
OldSlideID string
Content string
Locator string
}
type replacePagesPrepared struct {
PresentationID string
Plan []replacePagePlanItem
}
type replacePageResult struct {
OldSlideID string
NewSlideID string
Status string
Error string
RevisionID *int
}
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return nil, err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return nil, err
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return nil, err
}
if err := validateReplacePagesInput(pages); err != nil {
return nil, err
}
plan, err := buildReplacePagesPlan(pages)
if err != nil {
return nil, err
}
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
}
func parseReplacePages(raw string) ([]replacePageInput, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
}
out := make([]replacePageInput, 0, len(decoded))
for i, m := range decoded {
p := replacePageInput{}
if v, ok := m["slide_number"]; ok {
_ = v
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
}
if v, ok := m["slide_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
}
p.SlideID = s
}
if v, ok := m["content"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
}
p.Content = s
}
out = append(out, p)
}
return out, nil
}
func validateReplacePagesInput(pages []replacePageInput) error {
if len(pages) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
}
seenIDs := map[string]bool{}
for i, p := range pages {
id := strings.TrimSpace(p.SlideID)
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
}
if seenIDs[id] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
}
seenIDs[id] = true
if strings.TrimSpace(p.Content) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
}
if err := validateCompleteSlideXML(p.Content); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
}
}
return nil
}
func validateCompleteSlideXML(content string) error {
dec := xml.NewDecoder(strings.NewReader(content))
depth := 0
seenRoot := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if depth == 0 {
if seenRoot {
return fmt.Errorf("multiple root elements")
}
if t.Name.Local != "slide" {
return fmt.Errorf("root element is <%s>, want <slide>", t.Name.Local)
}
seenRoot = true
}
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(t)) != "" {
return fmt.Errorf("non-whitespace text outside root element")
}
}
}
if !seenRoot {
return fmt.Errorf("missing root element")
}
if depth != 0 {
return fmt.Errorf("unclosed XML element")
}
return nil
}
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
plan := make([]replacePagePlanItem, 0, len(pages))
for _, page := range pages {
id := strings.TrimSpace(page.SlideID)
plan = append(plan, replacePagePlanItem{
OldSlideID: id,
Content: page.Content,
Locator: "slide_id",
})
}
return plan, nil
}
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
for i, item := range resolved.Plan {
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
})
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": "<revision_returned_by_create>",
})
}
}
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
result := replacePageResult{
OldSlideID: item.OldSlideID,
Status: "pending",
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
createData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": revisionID},
map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
},
)
if err != nil {
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
newSlideID := common.GetString(createData, "slide_id")
if newSlideID == "" {
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
result.NewSlideID = newSlideID
if rev, ok := revisionFromData(createData); ok {
revisionID = rev
result.RevisionID = &rev
}
deleteData, err := runtime.CallAPITyped(
"DELETE",
slideURL,
map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": revisionID,
},
nil,
)
if err != nil {
result.Status = "delete_failed"
result.Error = err.Error()
return result, err
}
if rev, ok := revisionFromData(deleteData); ok {
result.RevisionID = &rev
}
result.Status = "replaced"
return result, nil
}
func revisionFromData(data map[string]interface{}) (int, bool) {
if _, ok := data["revision_id"]; !ok {
return 0, false
}
return int(common.GetFloat(data, "revision_id")), true
}
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(plan))
for _, item := range plan {
out = append(out, map[string]interface{}{
"old_slide_id": item.OldSlideID,
"insert_before_slide_id": item.OldSlideID,
"locator": item.Locator,
"action": "create_before_then_delete_old",
})
}
return out
}
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(results))
for _, result := range results {
m := map[string]interface{}{
"old_slide_id": result.OldSlideID,
"status": result.Status,
}
if result.NewSlideID != "" {
m["new_slide_id"] = result.NewSlideID
}
if result.Error != "" {
m["error"] = result.Error
}
if result.RevisionID != nil {
m["revision_id"] = *result.RevisionID
}
out = append(out, m)
}
return out
}
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
replaced := countReplacedPages(results)
return map[string]interface{}{
"replaced": replaced,
"failed": len(results) - replaced,
"total": len(results),
}
}
func countReplacedPages(results []replacePageResult) int {
n := 0
for _, result := range results {
if result.Status == "replaced" {
n++
}
}
return n
}
func hasReplacePageFailures(results []replacePageResult) bool {
for _, result := range results {
if result.Status == "create_failed" || result.Status == "delete_failed" {
return true
}
}
return false
}

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
}
reg.Register(createStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
}
reg.Register(deleteStub)
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody struct {
Slide struct {
Content string `json:"content"`
} `json:"slide"`
BeforeSlideID string `json:"before_slide_id"`
}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
}
if createBody.BeforeSlideID != "old2" {
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
}
if !strings.Contains(createBody.Slide.Content, "<slide") {
t.Fatalf("create content = %q", createBody.Slide.Content)
}
deleteURL := string(deleteStub.CapturedBody)
if deleteURL != "" {
t.Fatalf("delete body = %q, want empty", deleteURL)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["revision_id"] != float64(12) {
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["failed"] != float64(0) {
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
}
results, _ := data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
t.Fatalf("result = %#v", first)
}
}
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
})
pages := `[
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
data := env.Data
if data["status"] != "partial_failure" {
t.Fatalf("status = %v, want partial_failure", data["status"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
}
results, _ := data["results"].([]interface{})
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
first, _ := results[0].(map[string]interface{})
second, _ := results[1].(map[string]interface{})
if first["status"] != "create_failed" {
t.Fatalf("first status = %v, want create_failed", first["status"])
}
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
t.Fatalf("second result = %#v, want replaced with new2", second)
}
}
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
results, _ := env.Data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["status"] != "delete_failed" {
t.Fatalf("status = %v, want delete_failed", first["status"])
}
if first["new_slide_id"] != "new1" {
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
}
}
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
}
if out["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
}
plan, _ := out["plan"].([]interface{})
if len(plan) != 1 {
t.Fatalf("plan len = %d, want 1", len(plan))
}
item, _ := plan[0].(map[string]interface{})
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
t.Fatalf("plan item = %#v", item)
}
api, _ := out["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("api len = %d, want create/delete plan", len(api))
}
}
func TestReplacePagesValidationParam(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pages string
}{
{"empty pages", `[]`},
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
{"no locator", `[{"content":"<slide/>"}]`},
{"empty content", `[{"slide_id":"s1","content":" "}]`},
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", tt.pages,
"--as", "user",
})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *errs.ValidationError", err)
}
if ve.Param != "--pages" {
t.Fatalf("Param = %q, want --pages", ve.Param)
}
})
}
}
type replacePagesEnvelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
t.Helper()
var env replacePagesEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
}
if env.Data == nil {
t.Fatalf("missing data: %#v", env)
}
return env
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesXMLGet fetches the full XML presentation content and writes it to a
// local file, keeping the terminal output small for large decks.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
Description: "Fetch full presentation XML and save it to a local file",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("output")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
}
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
if runtime.Int("revision-id") < -1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
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("Fetch full presentation XML and save it to a local file")
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
dry.GET(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s",
validate.EncodePathSegment(presentationID),
)).
Params(params)
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
params,
nil,
)
if err != nil {
return err
}
presentation := common.GetMap(data, "xml_presentation")
content := common.GetString(presentation, "content")
if content == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
}
outputPath := runtime.Str("output")
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(outputPath)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
}
out := map[string]interface{}{
"xml_presentation_id": presentationID,
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
}
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
out["revision_id"] = int(revisionID)
}
if runtime.Bool("remove-attr-id") {
out["remove_attr_id"] = true
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"presentation_id": "pres_abc",
"revision_id": 7,
"content": xml,
},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "readback.xml",
"--revision-id", "7",
"--remove-attr-id",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "readback.xml")
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
}
if strings.Contains(stdout.String(), xml) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
t.Fatalf("revision_id query = %q, want 7", got)
}
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
t.Fatalf("remove_attr_id query = %q, want true", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
}
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, "readback.xml") {
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
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": "pres_real",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": `<presentation/>`,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
"--output", "wiki.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_real" {
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
}
}
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "../readback.xml",
"--as", "user",
})
if err == nil {
t.Fatal("expected unsafe output path error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if problem.Param != "--output" {
t.Fatalf("param = %q, want --output", problem.Param)
}
}

View File

@@ -73,16 +73,12 @@ var SearchTask = common.Shortcut{
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
if notice == "" {
notice, _ = data["notice"].(string)
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
@@ -119,9 +115,6 @@ var SearchTask = common.Shortcut{
"page_token": lastPageToken,
"has_more": lastHasMore,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No tasks found.")

Some files were not shown because too many files have changed in this diff Show More