mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
1 Commits
feat-ppe-s
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa7a92217 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,19 +263,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -283,28 +277,7 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -319,22 +292,15 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -344,24 +310,16 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,33 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
@@ -1398,7 +1371,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -14,13 +14,11 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -105,11 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Lark Slides Development Plans
|
||||
|
||||
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
|
||||
|
||||
约束:
|
||||
|
||||
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
|
||||
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
|
||||
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
|
||||
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,22 +10,20 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.65",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,73 +215,6 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -304,23 +237,13 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -36,6 +35,33 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -27,17 +27,12 @@ const (
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
|
||||
whiteboardTag = "whiteboard"
|
||||
whiteboardTypeAttr = "type"
|
||||
whiteboardPathAttr = "path"
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
@@ -63,11 +58,6 @@ type html5BlockStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
type whiteboardStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -246,248 +232,6 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
|
||||
if !strings.Contains(content, "<whiteboard") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteWhiteboardFileRefs(runtime, segment)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return rewrite(content)
|
||||
}
|
||||
|
||||
var rewriteErrs []error
|
||||
out := applyOutsideCodeFences(content, func(segment string) string {
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
rewriteErrs = append(rewriteErrs, rewriteErr)
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
|
||||
var rewriteErrs []error
|
||||
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
|
||||
if err != nil {
|
||||
rewriteErrs = append(rewriteErrs, err)
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
|
||||
startRaw, body, _, ok := splitWhiteboardElement(raw)
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
tag, err := parseWhiteboardStartTag(startRaw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(whiteboardPathAttr)
|
||||
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
|
||||
if !hasPath && !hasBodyPath {
|
||||
return raw, nil
|
||||
}
|
||||
if hasPath && strings.TrimSpace(body) != "" {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
|
||||
}
|
||||
if hasPath && hasBodyPath {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
|
||||
}
|
||||
|
||||
typRaw, ok := tag.attr(whiteboardTypeAttr)
|
||||
if !ok || strings.TrimSpace(typRaw) == "" {
|
||||
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
|
||||
}
|
||||
typ, ok := canonicalWhiteboardFileType(typRaw)
|
||||
if !ok {
|
||||
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
|
||||
}
|
||||
|
||||
if hasBodyPath {
|
||||
pathValue = bodyPath
|
||||
}
|
||||
data, err := readWhiteboardPath(runtime, pathValue, typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag.setAttr(whiteboardTypeAttr, typ)
|
||||
tag.removeAttrs(whiteboardPathAttr)
|
||||
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
|
||||
}
|
||||
|
||||
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing = strings.HasSuffix(trimmed, "/>")
|
||||
if selfClosing {
|
||||
return raw, "", true, true
|
||||
}
|
||||
startEnd := strings.Index(raw, ">")
|
||||
if startEnd < 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
|
||||
if endStart < 0 || endStart < startEnd {
|
||||
return "", "", false, false
|
||||
}
|
||||
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
|
||||
}
|
||||
|
||||
func whiteboardBodyPathRef(body string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func canonicalWhiteboardFileType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "svg":
|
||||
return "svg", true
|
||||
case "mermaid":
|
||||
return "mermaid", true
|
||||
case "plantuml":
|
||||
return "plantuml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
|
||||
}
|
||||
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
|
||||
WithParam("path").
|
||||
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
|
||||
WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func whiteboardExtAllowed(typ string, ext string) bool {
|
||||
for _, allowed := range whiteboardAllowedExts(typ) {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func whiteboardAllowedExts(typ string) []string {
|
||||
switch typ {
|
||||
case "svg":
|
||||
return []string{".svg"}
|
||||
case "mermaid":
|
||||
return []string{".mermaid", ".mmd"}
|
||||
case "plantuml":
|
||||
return []string{".plantuml", ".puml", ".pu", ".uml"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func whiteboardExtList(typ string) string {
|
||||
return strings.Join(whiteboardAllowedExts(typ), ", ")
|
||||
}
|
||||
|
||||
func exampleWhiteboardExt(typ string) string {
|
||||
exts := whiteboardAllowedExts(typ)
|
||||
if len(exts) == 0 {
|
||||
return "txt"
|
||||
}
|
||||
return strings.TrimPrefix(exts[0], ".")
|
||||
}
|
||||
|
||||
func whiteboardContentForType(typ string, data string) string {
|
||||
if typ == "svg" {
|
||||
return data
|
||||
}
|
||||
return escapeXMLText(data)
|
||||
}
|
||||
|
||||
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
|
||||
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
|
||||
messages := make([]string, 0, len(flatErrs))
|
||||
params := make([]errs.InvalidParam, 0, len(flatErrs))
|
||||
for _, err := range flatErrs {
|
||||
messages = append(messages, err.Error())
|
||||
params = append(params, whiteboardInvalidParamsFromError(err)...)
|
||||
}
|
||||
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
|
||||
WithParam("whiteboard").
|
||||
WithCause(errors.Join(flatErrs...))
|
||||
if len(params) > 0 {
|
||||
validationErr.WithParams(params...)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
|
||||
flatErrs := make([]error, 0, len(rewriteErrs))
|
||||
for _, err := range rewriteErrs {
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
|
||||
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
|
||||
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
flatErrs = append(flatErrs, err)
|
||||
}
|
||||
return flatErrs
|
||||
}
|
||||
|
||||
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return nil
|
||||
}
|
||||
if len(validationErr.Params) > 0 {
|
||||
return validationErr.Params
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
@@ -877,34 +621,6 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return whiteboardStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != whiteboardTag {
|
||||
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
@@ -914,15 +630,6 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
@@ -943,31 +650,6 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) setAttr(name string, value string) {
|
||||
for i, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
t.Attrs[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
@@ -992,25 +674,6 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(whiteboardTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
@@ -1031,18 +694,3 @@ func escapeXMLAttr(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ package doc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -118,61 +116,6 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
files := map[string]string{
|
||||
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||
"flow.mmd": "flowchart TD\nA --> B",
|
||||
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
got := body["content"].(string)
|
||||
for _, want := range []string{
|
||||
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `path="@`) {
|
||||
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||
}
|
||||
if _, ok := body["reference_map"]; ok {
|
||||
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
@@ -464,119 +407,6 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"missing.svg",
|
||||
"missing.mmd",
|
||||
"missing.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "missing.svg" cannot be read`,
|
||||
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"before.svg",
|
||||
"after.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "before.svg" cannot be read`,
|
||||
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||
})
|
||||
if strings.Contains(err.Error(), "inside.svg") {
|
||||
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "whiteboard" {
|
||||
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||
}
|
||||
if validationErr.Cause == nil {
|
||||
t.Fatal("expected aggregated error to preserve cause")
|
||||
}
|
||||
var childValidationErr *errs.ValidationError
|
||||
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||
}
|
||||
|
||||
gotParams := make(map[string]string, len(validationErr.Params))
|
||||
for _, param := range validationErr.Params {
|
||||
gotParams[param.Name] = param.Reason
|
||||
}
|
||||
if len(gotParams) != len(wantParams) {
|
||||
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for _, param := range wantParams {
|
||||
reason, ok := gotParams[param]
|
||||
if !ok {
|
||||
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||
}
|
||||
}
|
||||
for _, want := range wantMessages {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q:\n%v", want, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,10 +47,12 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, while wiki tokens can use --doc-type wiki or fallback after file-token-invalid"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -75,6 +77,7 @@ var DriveExport = common.Shortcut{
|
||||
// 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 {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -87,6 +90,7 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -106,6 +110,7 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -127,60 +132,93 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// 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()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
// 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").
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
return dry
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
}
|
||||
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").
|
||||
dry.Desc(desc).
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
if !source.WasURL {
|
||||
dry.Set("wiki_token_fallback", "if export task returns file token invalid, the CLI will resolve --token as a wiki node and retry once")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
// 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()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// 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" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
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(
|
||||
@@ -222,21 +260,23 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(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)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskWithWikiFallback(ctx, runtime, spec, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -274,7 +314,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// 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{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -284,7 +324,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -307,7 +347,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -357,7 +397,19 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -37,6 +44,20 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
WasURL bool
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -127,45 +148,49 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, base for bitable, and pptx/pdf for slides")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -173,15 +198,213 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv", "pdf"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base", "pdf"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
WasURL: true,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -193,8 +416,13 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -206,6 +434,99 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskWithWikiFallback(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err == nil {
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
if source.WasURL || !shouldRetryDriveExportAsWiki(err) {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
resolvedSpec, resolution, resolveErr := resolveDriveExportWikiSource(ctx, runtime, spec, spec.Token)
|
||||
if resolveErr != nil {
|
||||
return "", spec, driveExportWikiResolution{}, appendDriveExportRecoveryHint(
|
||||
err,
|
||||
fmt.Sprintf("export task rejected --token; attempted wiki node resolution also failed: %v", resolveErr),
|
||||
)
|
||||
}
|
||||
ticket, retryErr := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, retryErr
|
||||
}
|
||||
|
||||
func shouldRetryDriveExportAsWiki(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Code == 1069914
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,10 +33,36 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -72,17 +98,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
wantErr: "cannot be exported as base",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
wantErr: "cannot be exported as pptx",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -113,6 +139,29 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -440,6 +489,76 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-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",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -510,6 +629,318 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(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,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-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",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(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,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-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", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFallbackAfterFileTokenInvalid(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
retryCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_retry"},
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"docxResolved"`)
|
||||
},
|
||||
}
|
||||
reg.Register(retryCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_retry",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_retry",
|
||||
"file_name": "retry-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_retry/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="retry-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", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
var retryBody map[string]interface{}
|
||||
if err := json.Unmarshal(retryCreate.CapturedBody, &retryBody); err != nil {
|
||||
t.Fatalf("unmarshal retry export_tasks body: %v", err)
|
||||
}
|
||||
if retryBody["token"] != "docxResolved" {
|
||||
t.Fatalf("retry export_tasks body token = %v, want resolved docx token", retryBody["token"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, 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,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -184,7 +184,6 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -195,7 +194,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if aborted {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -233,7 +232,6 @@ var DrivePull = common.Shortcut{
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -300,7 +298,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -349,6 +347,15 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -35,7 +35,6 @@ type drivePushItem struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -49,7 +48,6 @@ type driveBatchFailureDecision struct {
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -242,7 +240,6 @@ var DrivePush = common.Shortcut{
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
aborted := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
@@ -269,7 +266,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -288,7 +284,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && aborted {
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -305,7 +301,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
@@ -337,7 +332,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -356,7 +350,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -369,7 +362,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -415,15 +407,10 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
if drivePushIsAlreadyDeleted(err) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||
continue
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
@@ -442,7 +429,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -580,7 +567,6 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Hint: decision.Hint,
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
@@ -627,10 +613,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Code == 1061044:
|
||||
decision.Class = "parent_node_missing"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
@@ -644,9 +626,22 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,65 +732,6 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061007,
|
||||
"msg": "file has been delete.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -1196,78 +1137,6 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -268,7 +268,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
aborted := false
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
@@ -287,21 +286,16 @@ var DriveSync = common.Shortcut{
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
@@ -310,7 +304,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -324,7 +318,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -336,7 +329,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -348,14 +341,9 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
@@ -364,7 +352,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -376,7 +363,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -410,7 +397,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -429,14 +415,9 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
@@ -454,7 +435,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -523,7 +503,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
@@ -552,7 +531,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": aborted,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -598,6 +577,15 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
|
||||
@@ -51,8 +51,9 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,19 +465,14 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,8 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
@@ -715,15 +715,9 @@ func markdownUploadProblem(err error, action string) error {
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
case 1061101:
|
||||
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||
case 233523001:
|
||||
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||
case 99991400:
|
||||
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -31,19 +30,27 @@ var MarkdownCreate = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateMarkdownSpec(runtime, spec, true)
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -64,9 +71,14 @@ var MarkdownCreate = common.Shortcut{
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -103,139 +115,3 @@ var MarkdownCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
return normalizeMarkdownCreateTargetSpec(spec)
|
||||
}
|
||||
|
||||
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||
if spec.FolderToken != "" {
|
||||
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.FolderToken = token
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.WikiToken = token
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||
}
|
||||
if ref.Type != "folder" {
|
||||
return "", markdownValidationParamError("--folder-token",
|
||||
"--folder-token must identify a Drive folder; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch markdownKnownResourceTokenKind(token) {
|
||||
case "wiki":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||
WithHint("Pass it with --wiki-token instead.")
|
||||
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token",
|
||||
"--wiki-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||
if strings.ContainsAny(token, "/?#") {
|
||||
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||
if err := validate.ResourceName(token, flagName); err != nil {
|
||||
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownKnownResourceTokenKind(token string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(token))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "wik"):
|
||||
return "wiki"
|
||||
case strings.HasPrefix(lower, "docx"):
|
||||
return "docx"
|
||||
case strings.HasPrefix(lower, "doc"):
|
||||
return "doc"
|
||||
case strings.HasPrefix(lower, "sht"):
|
||||
return "sheet"
|
||||
case strings.HasPrefix(lower, "bas"):
|
||||
return "bitable"
|
||||
case strings.HasPrefix(lower, "mn"):
|
||||
return "mindnote"
|
||||
case strings.HasPrefix(lower, "sld"):
|
||||
return "slides"
|
||||
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||
return "file"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,173 +446,6 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected folder-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() (string, error)
|
||||
wantMsg string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "wiki token passed as folder token",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||
wantMsg: "--folder-token looks like a wiki node token",
|
||||
wantHint: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "folder token path fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||
wantMsg: "--folder-token must be a raw token",
|
||||
wantHint: "full Lark URL",
|
||||
},
|
||||
{
|
||||
name: "doc token passed as wiki token",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||
wantMsg: "--wiki-token must be a wiki node token",
|
||||
wantHint: "",
|
||||
},
|
||||
{
|
||||
name: "wiki token query fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||
wantMsg: "--wiki-token must be a raw token",
|
||||
wantHint: "path/query/fragment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||
}
|
||||
if folderToken != "folder_token_raw" {
|
||||
t.Fatalf("folder token = %q", folderToken)
|
||||
}
|
||||
|
||||
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||
}
|
||||
if wikiToken != "wik_placeholder_raw" {
|
||||
t.Fatalf("wiki token = %q", wikiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||
t.Fatalf("quota hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||
p, ok = errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||
}
|
||||
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package wiki
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -27,17 +26,3 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
|
||||
func appendWikiProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -132,147 +130,6 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "wikcnABC", "--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected numeric space_id validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("expected numeric space_id validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list",
|
||||
"--space-id", "7211568716812369922",
|
||||
"--parent-node-token", "https://feishu.cn/docx/docxABC",
|
||||
"--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != "wikcnPARENT" {
|
||||
t.Fatalf("token = %q, want wikcnPARENT", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
|
||||
t.Fatalf("expected URL space-id validation error")
|
||||
} else {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "partial wiki path",
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := normalizeWikiNodeListParentToken(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent token validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
|
||||
got := wikiNodeListProblem(err, nil)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "page token is invalid or stale") {
|
||||
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -280,14 +137,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_1",
|
||||
"obj_token": "docx_1",
|
||||
"obj_type": "docx",
|
||||
@@ -297,7 +154,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
"has_child": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_2",
|
||||
"obj_token": "docx_2",
|
||||
"obj_type": "docx",
|
||||
@@ -313,7 +170,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -354,14 +211,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
@@ -378,7 +235,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -429,7 +286,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"name": "My Library",
|
||||
"space_type": "my_library",
|
||||
},
|
||||
@@ -439,14 +296,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
// Step 2: list nodes in the resolved space.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"node_token": "wik_personal_1",
|
||||
"title": "Personal Note",
|
||||
},
|
||||
@@ -477,8 +334,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,21 +758,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
|
||||
// test pins down the "default = single page" contract.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -945,14 +802,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_1",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_1",
|
||||
@@ -965,7 +822,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
|
||||
@@ -48,19 +48,27 @@ var WikiNodeList = common.Shortcut{
|
||||
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if spec.ParentNodeToken != "" {
|
||||
params["parent_node_token"] = spec.ParentNodeToken
|
||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
||||
params["parent_node_token"] = pt
|
||||
}
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
@@ -72,7 +80,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// When the caller passes my_library, +node-list must first resolve it
|
||||
// to the real per-user space_id before listing nodes, mirroring the
|
||||
// two-step orchestration used by +node-create.
|
||||
if spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return d.
|
||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||
@@ -83,17 +91,13 @@ var WikiNodeList = common.Shortcut{
|
||||
Set("space_id", "<resolved_space_id>")
|
||||
}
|
||||
return d.
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
||||
Params(params).
|
||||
Set("space_id", spec.SpaceID)
|
||||
Set("space_id", spaceID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaceID := spec.SpaceID
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
|
||||
// Resolve the my_library alias to the per-user real space_id before
|
||||
// listing, so the subsequent request hits a concrete space endpoint.
|
||||
@@ -106,7 +110,7 @@ var WikiNodeList = common.Shortcut{
|
||||
spaceID = resolved
|
||||
}
|
||||
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,104 +127,10 @@ var WikiNodeList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
type wikiNodeListSpec struct {
|
||||
SpaceID string
|
||||
ParentNodeToken string
|
||||
}
|
||||
|
||||
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
|
||||
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
|
||||
if err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
|
||||
}
|
||||
|
||||
func validateWikiNodeListSpaceID(spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id, not a URL or path",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
|
||||
}
|
||||
if !isDecimalWikiSpaceID(spaceID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDecimalWikiSpaceID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
if parentNodeToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(parentNodeToken, "://") {
|
||||
ref, ok := common.ParseResourceURL(parentNodeToken)
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token URL is unsupported",
|
||||
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
|
||||
}
|
||||
parentNodeToken = ref.Token
|
||||
}
|
||||
if strings.ContainsAny(parentNodeToken, "/?#") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parentNodeToken, nil
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
||||
pageSize := runtime.Int("page-size")
|
||||
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
||||
auto := wikiListShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
|
||||
@@ -243,7 +153,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||
return nil, false, "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -267,36 +177,6 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
return nodes, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
switch p.Code {
|
||||
case 131002:
|
||||
msg := strings.ToLower(p.Message)
|
||||
switch {
|
||||
case strings.Contains(msg, "page_token"):
|
||||
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
|
||||
case strings.Contains(msg, "space_id"):
|
||||
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
|
||||
default:
|
||||
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
|
||||
}
|
||||
case 131005:
|
||||
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
|
||||
case 131006:
|
||||
if runtime != nil && runtime.As().IsBot() {
|
||||
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
|
||||
} else {
|
||||
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
|
||||
}
|
||||
case 99991400:
|
||||
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(m, "space_id"),
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -82,13 +82,12 @@
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -418,7 +417,7 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`;CLI 会在写入前读取文件并展开为内联内容。
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
@@ -58,8 +56,6 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`;PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`。
|
||||
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
|
||||
@@ -37,6 +37,7 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;URL 会自动解析类型和 token,Wiki URL 会自动解析到底层 `obj_token/obj_type`。只有手里只有裸 token 时才使用 `--token <TOKEN> --doc-type <doc|docx|sheet|bitable|slides|wiki>`;裸 Wiki token 推荐显式传 `--doc-type wiki`,CLI 会先解析 Wiki node;如果误按底层类型传参且 export task 返回 `file token invalid`,CLI 也会尝试按 Wiki node token 解析并重试一次。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -13,6 +13,29 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL,CLI 自动解析类型和 token
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# Wiki URL 也推荐直接传,CLI 会先解析到底层 obj_token/obj_type
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# 只有裸 Wiki node token 时,显式传 --doc-type wiki,让 CLI 先解析到底层文档类型
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type wiki \
|
||||
--file-extension pdf
|
||||
|
||||
# 兼容兜底:如果误把 Wiki token 搭配底层类型传入,且 export task 返回 file token invalid,
|
||||
# CLI 会尝试按 Wiki node token 解析并重试一次;但新脚本仍推荐上面的 --doc-type wiki
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -96,8 +119,9 @@ lark-cli drive +export \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,推荐优先使用;CLI 自动解析类型和 token,Wiki URL 会解析到底层 `obj_token/obj_type` |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档裸 token;裸 token 必须同时传 `--doc-type`。裸 Wiki node token 推荐传 `--doc-type wiki`,CLI 会先解析到底层 `obj_token/obj_type`;如误传底层类型并触发 export task `file token invalid`,CLI 会尝试按 Wiki node token 解析并重试一次 |
|
||||
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token,解析后会按真实底层类型发起导出 |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
@@ -107,12 +131,17 @@ lark-cli drive +export \
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type;尤其是 Wiki URL,CLI 会自动解包到底层资源
|
||||
- `--url` 和 `--token` 互斥
|
||||
- 裸 `--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
|
||||
- `doc` 支持导出为 `docx` / `pdf`
|
||||
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
|
||||
- `sheet` 支持导出为 `xlsx` / `csv` / `pdf`
|
||||
- `bitable` 支持导出为 `xlsx` / `csv` / `base` / `pdf`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- 如果格式不匹配,CLI 会返回 typed validation error,并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -121,8 +150,7 @@ lark-cli drive +export \
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--url "<DOCX_URL>" \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable`) |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`。
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
@@ -96,7 +95,6 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 删除阶段如果服务端返回 `1061007 file has been delete`,说明目标远端文件在本次 DELETE 前已经不存在;这已经满足 `--delete-remote` 的目标状态,输出会记为 `action: "already_deleted"`,不计入 `summary.failed`,也不计入 `summary.deleted_remote`。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
@@ -112,46 +110,22 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0,
|
||||
"aborted": false
|
||||
"deleted_remote": 0
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "already_deleted"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "...", "hint": "...", "phase": "delete", "error_class": "...", "code": 0, "subtype": "...", "retryable": false}
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
|
||||
|
||||
## 失败处理与 agent 行为
|
||||
|
||||
`+push` 的失败项带结构化字段,agent 必须优先读 `items[].error_class` / `phase` / `code`,不要只看自然语言 `error` 文本。`summary.aborted=true` 表示命令已经遇到终止性错误并停止后续批处理;这时**不要原样重试**,先修复根因。
|
||||
|
||||
常见终止性错误:
|
||||
|
||||
| `error_class` | 常见 `code` | 含义 | Agent 应对 |
|
||||
|---|---:|---|---|
|
||||
| `app_scope_missing` | `99991672` | 应用身份缺少 Drive / 文件夹相关 scope | 停止重试,引导开通错误里列出的应用身份权限,例如 `space:folder:create` 或 `drive:drive` |
|
||||
| `user_scope_missing` | `99991679` | 用户身份缺少授权 | 停止重试,走 `lark-cli auth login --scope ...` 补错误里列出的 scope |
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
非终止但需要解释的状态:
|
||||
|
||||
- `file_size_limit` / `1061043`:文件超过 Drive 上传限制。不要继续尝试同一文件;改拆分或换存储方式。
|
||||
- `upload_size_mismatch` / `1062009`:本地文件在上传过程中发生变化,或声明大小与实际读取大小不一致。重新扫描本地文件后再 push。
|
||||
- `remote_not_found` / `1061007`:一般表示远端文件已不存在。删除阶段的 `1061007` 会被视为 `already_deleted` 成功项;其他阶段需重新列表确认远端状态。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -79,7 +79,7 @@ metadata:
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -96,14 +96,13 @@ metadata:
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
9. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
10. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
11. **已读回执** —
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -120,8 +119,6 @@ metadata:
|
||||
- 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
|
||||
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
|
||||
- 撤回已发送邮件:撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
|
||||
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
|
||||
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
|
||||
- 收信规则:创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
|
||||
- 分享邮件到 IM:分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
|
||||
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
|
||||
@@ -195,7 +192,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 编辑转发草稿
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# mail +message-modify
|
||||
|
||||
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
|
||||
|
||||
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
|
||||
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
|
||||
lark-cli mail +message-modify --message-ids <id> --add-folder archive
|
||||
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
|
||||
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
|
||||
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
|
||||
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
|
||||
|
||||
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
|
||||
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.
|
||||
@@ -1,41 +0,0 @@
|
||||
# mail +message-trash
|
||||
|
||||
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
|
||||
|
||||
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
|
||||
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
|
||||
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.
|
||||
@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 相关命令
|
||||
|
||||
@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 编辑回复草稿
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.2.2
|
||||
version: 1.2.1
|
||||
description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -25,8 +25,7 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`quota_exceeded`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate_limit`、`server_error` 或临时网络错误才做有限退避重试。
|
||||
- `markdown +create` 的目标参数不要猜:Drive 文件夹用 `--folder-token`,Wiki 节点用 `--wiki-token`。如果用户给的是 URL,可以直接传完整 URL;CLI 会归一成 token。不要把 doc/sheet/wiki URL 放进 `--folder-token` 试错。
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -32,21 +32,11 @@ lark-cli markdown +create \
|
||||
--folder-token fldcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定文件夹(可直接传 Drive folder URL)
|
||||
lark-cli markdown +create \
|
||||
--folder-token "https://feishu.cn/drive/folder/fldcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点
|
||||
lark-cli markdown +create \
|
||||
--wiki-token wikcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点(可直接传 wiki URL)
|
||||
lark-cli markdown +create \
|
||||
--wiki-token "https://feishu.cn/wiki/wikcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
@@ -58,8 +48,8 @@ lark-cli markdown +create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
@@ -68,8 +58,6 @@ lark-cli markdown +create \
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- `--folder-token` 与 `--wiki-token` 互斥
|
||||
- `--folder-token` 只能是 Drive 文件夹;不要传 wiki/doc/sheet/base/file token 或 URL
|
||||
- `--wiki-token` 只能是 Wiki 节点;如果只有 docx/sheet/base 等文档 URL,先用 `lark-cli wiki +node-get --node-token <url>` 解析出 `node_token`
|
||||
- `--name` 必须带 `.md` 后缀
|
||||
- `--file` 指向的本地文件名也必须带 `.md` 后缀
|
||||
- 传 `--wiki-token` 时,返回值中不会附带 `/file/<token>` URL,因为 wiki 承载文件没有稳定的独立 file URL
|
||||
@@ -100,14 +88,6 @@ lark-cli markdown +create \
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `not_found` / `1061044`:父目录或 wiki 节点不存在,或 token 类型放错参数。修正 `--folder-token` / `--wiki-token` 后再试,不要重复提交同一参数。
|
||||
- `quota_exceeded` / `1061101`:目标存储空间配额已满。释放空间、换父目录/节点或请管理员扩容后再试。
|
||||
- `permission_denied` / `missing_scope`:区分身份处理。`--as user` 看用户授权和目标 ACL;`--as bot` 看应用 scope 与目标目录/节点 ACL。
|
||||
- `rate_limit`:停止立即重试,使用退避。
|
||||
- `server_error` / `233523001`:可以稍后有限重试;若重复出现,保留 `log_id` / request id 给服务端排查。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
|
||||
@@ -15,7 +15,6 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle;发布层另走 `+create-svglide` 后续计划 | `references/svg-slides/README.md`、`scripts/validate_svg_deck.mjs`、`scripts/svg_slides_bundle.mjs` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
@@ -30,8 +29,6 @@ metadata:
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义,XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# create-svglide boundary study
|
||||
|
||||
## Goal
|
||||
|
||||
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
|
||||
|
||||
The central rule is:
|
||||
|
||||
```text
|
||||
slides +create is a thin publisher for already-authored slide XML.
|
||||
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
|
||||
```
|
||||
|
||||
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
|
||||
|
||||
## Source Surface
|
||||
|
||||
| Area | Files | Why it matters |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
|
||||
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
|
||||
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
|
||||
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
|
||||
|
||||
## `slides +create` Responsibility Matrix
|
||||
|
||||
| Responsibility | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
|
||||
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
|
||||
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
|
||||
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
|
||||
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | Larger decks must use the lower-level page-create API after container creation. |
|
||||
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
|
||||
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
|
||||
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
|
||||
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
|
||||
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
|
||||
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
|
||||
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
|
||||
|
||||
## Behavior Locks From Tests
|
||||
|
||||
| Behavior | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
|
||||
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
|
||||
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
|
||||
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
|
||||
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
|
||||
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
|
||||
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
|
||||
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
|
||||
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
|
||||
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
|
||||
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
|
||||
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
|
||||
|
||||
## `slides +create` Does Not Do
|
||||
|
||||
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
|
||||
| --- | --- | --- |
|
||||
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
|
||||
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
|
||||
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
|
||||
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
|
||||
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
|
||||
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
|
||||
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
|
||||
|
||||
## Counterexamples
|
||||
|
||||
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
|
||||
| --- | --- | --- |
|
||||
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
|
||||
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
|
||||
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
|
||||
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
|
||||
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
|
||||
|
||||
## `slides +create-svglide` Allowed Extra Responsibilities
|
||||
|
||||
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
|
||||
|
||||
| Extra responsibility | Allowed because | Limit |
|
||||
| --- | --- | --- |
|
||||
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
|
||||
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
|
||||
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
|
||||
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
|
||||
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
|
||||
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
|
||||
|
||||
## `slides +create-svglide` Must Not Own
|
||||
|
||||
| Responsibility | Owner |
|
||||
| --- | --- |
|
||||
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
|
||||
| SVG authoring | Agent or runner before publish |
|
||||
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
|
||||
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
|
||||
| Readback as default success criterion | E2E or optional verification flag |
|
||||
| PPE/Whistle routing as core naming | Environment/profile layer only |
|
||||
|
||||
## MVP Scope
|
||||
|
||||
Recommended first implementation:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP behavior:
|
||||
|
||||
1. Parse manifest.
|
||||
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
|
||||
3. Create presentation shell.
|
||||
4. Upload local assets declared in the manifest.
|
||||
5. Submit pages to the backend.
|
||||
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
|
||||
|
||||
MVP exclusions:
|
||||
|
||||
1. No prompt input.
|
||||
2. No generation stages.
|
||||
3. No preview repair.
|
||||
4. No default readback.
|
||||
5. No PPE-specific command name, directory name, or type name.
|
||||
|
||||
## Test Boundary For `+create-svglide`
|
||||
|
||||
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
|
||||
|
||||
| Test area | Required proof |
|
||||
| --- | --- |
|
||||
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
|
||||
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
|
||||
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
|
||||
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
|
||||
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
|
||||
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
|
||||
|
||||
## Team Finding
|
||||
|
||||
The effective research team for this boundary is:
|
||||
|
||||
| Role | Scope |
|
||||
| --- | --- |
|
||||
| Code Reader | Extract runtime responsibilities from Go implementation. |
|
||||
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
|
||||
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
|
||||
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
|
||||
|
||||
The team's proof standard is not "we read the files"; it is:
|
||||
|
||||
```text
|
||||
Every proposed +create-svglide responsibility must map to either:
|
||||
1. an existing +create responsibility, or
|
||||
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
|
||||
```
|
||||
@@ -1,160 +0,0 @@
|
||||
# create-svglide 边界研究
|
||||
|
||||
## 目标
|
||||
|
||||
把 `slides +create` 作为 `slides +create-svglide` 的设计约束样本。
|
||||
|
||||
核心规则是:
|
||||
|
||||
```text
|
||||
slides +create 是已经写好的 slide XML 的薄发布器。
|
||||
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
|
||||
```
|
||||
|
||||
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
|
||||
|
||||
## 研究范围
|
||||
|
||||
| 范围 | 文件 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut 实现 | `shortcuts/slides/slides_create.go`、`shortcuts/slides/helpers.go`、`shortcuts/slides/slides_media_upload.go`、`shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
|
||||
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
|
||||
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go`、`tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
|
||||
| Skill 与 references | `skills/lark-slides/SKILL.md`、`skills/lark-slides/references/lark-slides-create.md`、`xml-schema-quick-ref.md`、`validation-checklist.md`、`troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
|
||||
|
||||
## `slides +create` 职责矩阵
|
||||
|
||||
| 职责 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| 注册一个名为 `slides +create` 的写操作 shortcut,支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43`、`shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
|
||||
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
|
||||
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
|
||||
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
|
||||
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 更大的 deck 应先创建容器,再用底层 page-create API 追加页面。 |
|
||||
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
|
||||
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
|
||||
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177`、`shortcuts/slides/slides_media_upload.go:119-138`、`shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
|
||||
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
|
||||
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
|
||||
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
|
||||
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217`、`shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
|
||||
|
||||
## 测试锁定的行为
|
||||
|
||||
| 行为 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| user 模式创建返回 `xml_presentation_id`、`title`、`url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
|
||||
| 省略 `--title` 时,dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
|
||||
| `--slides` 会先创建 deck,再添加页面,最后返回 `slide_ids` 和 `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
|
||||
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
|
||||
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
|
||||
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
|
||||
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
|
||||
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
|
||||
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
|
||||
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602`、`shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
|
||||
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`、`tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
|
||||
| Bot 授权是非致命三态:granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
|
||||
|
||||
## `slides +create` 不负责的事情
|
||||
|
||||
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
|
||||
| --- | --- | --- |
|
||||
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43`、`shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
|
||||
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
|
||||
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
|
||||
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221`、`tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
|
||||
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
|
||||
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21`、`shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
|
||||
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127`、`skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
|
||||
|
||||
## 反例
|
||||
|
||||
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
|
||||
| --- | --- | --- |
|
||||
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API;`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
|
||||
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件;XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
|
||||
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
|
||||
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
|
||||
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
|
||||
|
||||
## `slides +create-svglide` 允许新增的职责
|
||||
|
||||
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
|
||||
|
||||
| 额外职责 | 允许原因 | 限制 |
|
||||
| --- | --- | --- |
|
||||
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同,SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
|
||||
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
|
||||
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
|
||||
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
|
||||
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
|
||||
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide,不要假设 CLI 必须转 XML。 |
|
||||
|
||||
## `slides +create-svglide` 必须不拥有的职责
|
||||
|
||||
| 职责 | 所属边界 |
|
||||
| --- | --- |
|
||||
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
|
||||
| SVG authoring | agent 或 runner,在发布前完成 |
|
||||
| preview rendering、preview lint、repair loop | skill scripts 或 runner,在发布前完成 |
|
||||
| 视觉质量评分 | skill/scripts/quality gate,不属于 shortcut `Execute` |
|
||||
| readback 作为默认成功标准 | E2E 或可选验证 flag |
|
||||
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
|
||||
|
||||
## MVP 范围
|
||||
|
||||
推荐第一版实现:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP 行为:
|
||||
|
||||
1. 解析 manifest。
|
||||
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
|
||||
3. 创建 presentation 外壳。
|
||||
4. 上传 manifest 声明的本地素材。
|
||||
5. 把页面提交给后端。
|
||||
6. 输出 `xml_presentation_id`、`url`、`page_ids` 或 `slide_ids`、上传素材数量,以及失败时的部分进度上下文。
|
||||
|
||||
MVP 排除项:
|
||||
|
||||
1. 不接受 prompt 输入。
|
||||
2. 不包含生成阶段。
|
||||
3. 不做 preview repair。
|
||||
4. 不默认 readback。
|
||||
5. 不在命令名、目录名或类型名中包含 PPE。
|
||||
|
||||
## `+create-svglide` 测试边界
|
||||
|
||||
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
|
||||
|
||||
| 测试范围 | 必须证明 |
|
||||
| --- | --- |
|
||||
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
|
||||
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
|
||||
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
|
||||
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
|
||||
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为;grant 失败不升级成 create 失败。 |
|
||||
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
|
||||
|
||||
## Team 结论
|
||||
|
||||
适合研究这个边界的 team 是:
|
||||
|
||||
| 角色 | 范围 |
|
||||
| --- | --- |
|
||||
| Code Reader | 从 Go 实现中抽取运行时职责。 |
|
||||
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
|
||||
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
|
||||
| Architect/Skeptic | 拒绝过宽 scope,只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
|
||||
|
||||
这个 team 的证明标准不是“读过文件”,而是:
|
||||
|
||||
```text
|
||||
每一个 proposed +create-svglide 职责都必须映射到:
|
||||
1. 一个已有 +create 职责;或
|
||||
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
|
||||
```
|
||||
@@ -1,41 +0,0 @@
|
||||
# SVG Slides Local Generation
|
||||
|
||||
This reference family is for local SVG Slides generation and validation.
|
||||
|
||||
It is not the Lark Slides XML/SXSD workflow and it is not the publish shortcut. Use it to produce a local publish-ready bundle that a future `slides +create-svglide` publisher can consume.
|
||||
|
||||
## Read Routes
|
||||
|
||||
| Task | Read first | Then read |
|
||||
|---|---|---|
|
||||
| Generate a new SVG deck | `workflow.md` | `design-brief.md`, `protocol.md`, `authoring-rules.md`, `visual-design.md`, `validation.md` |
|
||||
| Repair protocol failures | `validation.md` | `protocol.md`, `authoring-rules.md` |
|
||||
| Improve visual quality | `visual-design.md` | `design-brief.md`, `workflow.md` |
|
||||
| Use charts | `chart-workflow.md` | `protocol.md`, `validation.md` |
|
||||
| Continue an existing deck | `editing-existing-decks.md` | `workflow.md`, `protocol.md` |
|
||||
| Audit provenance | `source/split-manifest.json` | `source/full.debranded.md` |
|
||||
|
||||
## Boundary
|
||||
|
||||
Generation and validation produce a local publish-ready bundle.
|
||||
|
||||
A future SVG Slides publisher consumes this bundle. That publishing path is intentionally outside this reference family.
|
||||
|
||||
A local bundle may set `publish_ready=true`; it must not claim it is published.
|
||||
|
||||
## Canvas Decision
|
||||
|
||||
This CLI adaptation uses a 960x540 SVG canvas: `viewBox="0 0 960 540"`.
|
||||
|
||||
The source snapshot is preserved for provenance and coverage audit. Where the source describes a different default canvas, the CLI adaptation layer intentionally normalizes generated SVG Slides to 960x540.
|
||||
|
||||
## Required Local Gates
|
||||
|
||||
1. `node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir> --json`
|
||||
2. `node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"`
|
||||
3. Browser text-boundary check when Playwright is available.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: title
|
||||
- Coverage mode: routing entry; source text is preserved in `source/full.debranded.md`, while this file points workers to the coverage-preserving split docs.
|
||||
@@ -1,79 +0,0 @@
|
||||
# SVG Slides Authoring Rules
|
||||
|
||||
## Required Authoring Pattern
|
||||
|
||||
Write complete slide files. A slide edit is not a fragment, patch, or HTML page.
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Optional `<defs>`.
|
||||
2. One background as the first rendered child.
|
||||
3. Top-level shapes, images, charts, groups, and optional notes.
|
||||
|
||||
Every rendered element that the slide engine must understand needs the appropriate `slide:role`. Do not depend on generic browser rendering when the protocol has an explicit semantic role.
|
||||
|
||||
## Forbidden Constructs
|
||||
|
||||
Do not use:
|
||||
|
||||
- `<style>` blocks;
|
||||
- `class=`;
|
||||
- `<div>` or `<section>` wrappers in text `foreignObject`;
|
||||
- bare text under `foreignObject`;
|
||||
- SVG `<text>`;
|
||||
- SVG `<marker>`;
|
||||
- hex colors;
|
||||
- named colors;
|
||||
- `none` for `fill` or `stroke`;
|
||||
- role-less primitives in the rendered slide body.
|
||||
|
||||
## Text Boxes
|
||||
|
||||
Use plain text boxes for text-only content:
|
||||
|
||||
```xml
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="96" y="96" width="640" height="120" style="font-size:32px;color:rgba(15,23,42,1);line-height:1.2">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Main argument</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Use shape-with-text only when the object is truly one styled box with one text block. If the card has multiple parts, use `<g slide:role="group">`.
|
||||
|
||||
## Image Elements
|
||||
|
||||
Use images when there is a real image asset or generated visual. The SVG file references the local asset path.
|
||||
|
||||
Informational images such as charts, diagrams, screenshots, and infographics must preserve their original ratio. Decorative images may be composed more freely, but should still fit the resolved design brief.
|
||||
|
||||
Unless the user explicitly requests no images, cover, section divider, and closing pages should use a large hero image or generated visual. Full-bleed image backgrounds use `<image slide:role="background">`; large non-background images use `<image slide:role="image" slide:shape-type="image">`.
|
||||
|
||||
When text sits on an image, place a semi-transparent `<rect slide:role="shape" slide:shape-type="shape">` scrim or a solid text zone after the image and before the text. Do not use SVG `<mask>` for this readability layer.
|
||||
|
||||
Generated cover, section divider, or closing images must not contain baked-in text. Render text as slide text on top of the image.
|
||||
|
||||
## Chart Embeds
|
||||
|
||||
A chart is an external SVG sidecar referenced by:
|
||||
|
||||
```xml
|
||||
<rect slide:role="chart" href="resources/charts/example.svg" x="120" y="180" width="800" height="500"/>
|
||||
```
|
||||
|
||||
Do not hand-draw a chart from primitives when the slide's point depends on a real quantitative data series. Use the chart workflow to generate the sidecar first.
|
||||
|
||||
## Custom Paths
|
||||
|
||||
Custom paths require accurate bounds. `slide:width` and `slide:height` describe the real extent of the path data, not the full canvas.
|
||||
|
||||
If a path has not been normalized, measure its bounding box before writing the final slide. Oversized path boxes make selection, hit testing, and layout misleading.
|
||||
|
||||
## Grouped Cards
|
||||
|
||||
Use `<g slide:role="group">` for a multi-element cluster: card background, badge, icon, title, body, chart, image, or connector. Each child still carries its own role.
|
||||
|
||||
Do not use `<g slide:role="shape">` as a generic container. It is only for the shape-with-text form.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: slides_edit_tool, image_usage, compute_custom_shape_bbox_tool
|
||||
- Coverage mode: preserve authoring constraints and tool semantics that affect generated SVG structure.
|
||||
@@ -1,64 +0,0 @@
|
||||
# SVG Slides Chart Workflow
|
||||
|
||||
## When To Use A Chart
|
||||
|
||||
Use a chart when the slide's point depends on a real quantitative series:
|
||||
|
||||
- trend;
|
||||
- multi-category comparison;
|
||||
- part-to-whole split;
|
||||
- distribution;
|
||||
- ranking;
|
||||
- two-dimensional positioning.
|
||||
|
||||
For single numbers or trivial two-bucket comparisons, prefer a large text callout unless the comparison needs a chart to be understood.
|
||||
|
||||
## When Not To Use A Chart
|
||||
|
||||
Do not generate a chart for vague, unsourced, decorative, or invented data. Do not choose a chart type because the raw data happens to look compatible; choose it because the takeaway requires that representation.
|
||||
|
||||
When in doubt, a sorted bar chart is safer than a pie or doughnut.
|
||||
|
||||
## Chart Sidecar Contract
|
||||
|
||||
A chart is generated as an SVG sidecar before slide authoring and embedded by reference.
|
||||
|
||||
The generation request must decide the takeaway first. The takeaway must be faithful to the data and short enough to guide chart design.
|
||||
|
||||
The request must include:
|
||||
|
||||
- chart type;
|
||||
- JSON data matching that type;
|
||||
- style matching the destination slide;
|
||||
- actual on-slide width and height;
|
||||
- output path under `resources/charts/`.
|
||||
|
||||
The declared chart width should match the embed width. Chart internals derive text size from width. Do not declare a wide chart and embed it in a narrow slot.
|
||||
|
||||
## Embed Contract
|
||||
|
||||
Embed a generated chart with:
|
||||
|
||||
```xml
|
||||
<rect slide:role="chart" href="resources/charts/name.svg" x="120" y="180" width="800" height="500"/>
|
||||
```
|
||||
|
||||
The embed width and height must match the chart sidecar's intended display size. Keep a 16:10-ish chart area when possible to avoid letterboxing.
|
||||
|
||||
One chart should carry one distinct insight. Pair charts with short callouts or labels, and vary chart composition across the deck.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
Static deck validation confirms the chart placeholder shape, not the correctness of the chart sidecar data. Review chart sidecars for:
|
||||
|
||||
- source-backed data;
|
||||
- truthful takeaway;
|
||||
- readable labels;
|
||||
- width at or above the practical floor;
|
||||
- matching palette;
|
||||
- intact `href`.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: generate_svg_chart_tool
|
||||
- Coverage mode: preserve chart generation, data contract, rendering constraints, and validation expectations.
|
||||
@@ -1,80 +0,0 @@
|
||||
# SVG Slides Design Brief
|
||||
|
||||
## Inputs
|
||||
|
||||
Resolve the design brief after these inputs are known:
|
||||
|
||||
- topic and goal;
|
||||
- audience;
|
||||
- delivery mode;
|
||||
- language;
|
||||
- page count when known;
|
||||
- source material;
|
||||
- user-fixed brand, color, or content constraints;
|
||||
- one to three short visual-direction phrases.
|
||||
|
||||
Do not ask the user to choose tone, density, palette, or typography unless they volunteered hard constraints.
|
||||
|
||||
## Output Contract
|
||||
|
||||
The brief must produce:
|
||||
|
||||
- `narrative_spine`;
|
||||
- `depth`;
|
||||
- `tone`;
|
||||
- `visual_system`.
|
||||
|
||||
These outputs govern outline, content density, wording, asset choices, typography, color, layout, and decoration.
|
||||
|
||||
## narrative_spine
|
||||
|
||||
`narrative_spine` defines the slide sequence discipline. It is the default source of order, sectioning, and narrative movement.
|
||||
|
||||
The user can override it by giving or editing an outline. After that point, the user outline wins.
|
||||
|
||||
## depth
|
||||
|
||||
`depth` decides altitude and density:
|
||||
|
||||
- how much context each slide carries;
|
||||
- what to include and exclude;
|
||||
- how many main points per slide;
|
||||
- how source evidence should appear;
|
||||
- whether a page should split instead of cram.
|
||||
|
||||
## tone
|
||||
|
||||
`tone` controls writing style and evidence posture. It should reflect the audience and delivery mode.
|
||||
|
||||
Presented decks can use shorter on-slide wording because the speaker carries context. Self-read decks need more complete explanatory text but still must avoid walls of text.
|
||||
|
||||
## visual_system
|
||||
|
||||
`visual_system` is the authority for look and feel. It should include:
|
||||
|
||||
- color logic;
|
||||
- typography category and treatment;
|
||||
- layout grammar;
|
||||
- imagery or material direction;
|
||||
- page-role imagery defaults for cover, section divider, and closing pages;
|
||||
- decoration and motif rules;
|
||||
- constraints to avoid.
|
||||
|
||||
Unless the user explicitly requests no images, `visual_system` must specify how cover, section divider, and closing pages use a high-impact hero image or generated visual. The brief should describe the imagery subject, treatment, crop attitude, and how foreground text stays readable.
|
||||
|
||||
Font mapping must preserve the same category and treatment. Do not swap serif and sans, ignore uppercase treatment, or pick generic fonts when the brief calls for a distinctive style.
|
||||
|
||||
## How It Drives Generation
|
||||
|
||||
Use the brief in this order:
|
||||
|
||||
1. Shape the outline from `narrative_spine`.
|
||||
2. Size the content from `depth`.
|
||||
3. Write titles and evidence from `tone`.
|
||||
4. Build the deck-level style from `visual_system`.
|
||||
5. Author each slide's layout from the content logic plus the visual system.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: resolve_design_brief
|
||||
- Coverage mode: preserve design brief inputs, output contract, and downstream influence on outline and page authoring.
|
||||
@@ -1,39 +0,0 @@
|
||||
# SVG Slides Editing Existing Decks
|
||||
|
||||
## Continue Existing Deck
|
||||
|
||||
When the user asks to continue, edit, extend, or repair an existing uploaded deck, operate on the existing converted project instead of recreating from scratch.
|
||||
|
||||
Preserve every existing page unless the user asks to change it. A page with minimal content should remain minimal if that is what the source deck contained.
|
||||
|
||||
## Preserve Existing Pages
|
||||
|
||||
For text or layout changes, edit only the target slide files. Preserve styling by default. Restyle only when the user explicitly asks.
|
||||
|
||||
Preserve media, chart, video, and audio blocks verbatim when the request does not touch them.
|
||||
|
||||
## Add Or Delete Pages
|
||||
|
||||
Add pages through the organize workflow, then author the new standalone SVG pages.
|
||||
|
||||
Delete only pages the user asked to remove. Do not rerun the new-deck outline workflow over an existing deck; it can overwrite existing slide files and lose original pages.
|
||||
|
||||
## Template Reference Boundary
|
||||
|
||||
An uploaded reference can mean two different things:
|
||||
|
||||
- Continue or edit this deck: preserve and modify that deck.
|
||||
- Create a new deck inspired by this reference: author fresh SVG using the normal create workflow.
|
||||
|
||||
Clarify when the user's wording does not identify which behavior they want.
|
||||
|
||||
## PPTX Conversion Boundary
|
||||
|
||||
Converted decks may contain imported chart placeholders or media. Preserve legacy chart references unless the user asks to update chart data, type, theme, or emphasis.
|
||||
|
||||
If a chart is resized materially, regenerate the chart sidecar with the new dimensions rather than only squeezing the existing placeholder.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: slide_organize_tool, slides_convert_tool, slides_parse_template_tool
|
||||
- Coverage mode: preserve existing-deck continuation, conversion, and template parsing boundaries without turning them into publish behavior.
|
||||
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
|
||||
</foreignObject>
|
||||
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,19 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
|
||||
<g slide:role="group" id="card_primary">
|
||||
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
|
||||
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 753 B |
@@ -1,94 +0,0 @@
|
||||
# SVG Slides Protocol
|
||||
|
||||
## Canvas
|
||||
|
||||
- Each page is one standalone SVG file.
|
||||
- Root must contain `xmlns="http://www.w3.org/2000/svg"`.
|
||||
- Root must contain `xmlns:slide="https://slides.bytedance.com/ns"`.
|
||||
- Root must contain `slide:role="slide"`.
|
||||
- Root must contain an `id`.
|
||||
- Root must contain `viewBox="0 0 960 540"`.
|
||||
- Child coordinates are in viewBox units.
|
||||
- Do not rely on HTML document behavior. SVG nodes use SVG semantics; XHTML appears only inside approved `foreignObject` children.
|
||||
- This 960x540 canvas is the CLI adaptation target. The preserved source snapshot may mention other defaults, but generated local bundles must use 960x540.
|
||||
|
||||
## Background
|
||||
|
||||
- Exactly one rendered background is required.
|
||||
- Optional `<defs>` may appear first.
|
||||
- The first rendered child after optional `<defs>` must be a `<rect>` or `<image>` with `slide:role="background"`.
|
||||
- Background must cover the full canvas.
|
||||
- Gradient backgrounds must reference gradients declared in the same slide's `<defs>`.
|
||||
- A full-bleed image background should be an `<image slide:role="background">`.
|
||||
- Text scrims over image backgrounds are normal shape overlays after the background, not additional backgrounds.
|
||||
|
||||
## Text
|
||||
|
||||
- Plain text uses `foreignObject slide:role="shape" slide:shape-type="text"`.
|
||||
- Text `foreignObject` needs numeric `x`, `y`, `width`, and `height`.
|
||||
- The first direct XHTML child must be `p`, `ul`, `ol`, `h1`, `h2`, `h3`, or `small`.
|
||||
- Do not wrap text in `div` or `section`.
|
||||
- Do not put bare text directly under `foreignObject`.
|
||||
- Text style belongs in `style`.
|
||||
- `font-size` must include `px`.
|
||||
- Text color must be `rgb(...)` or `rgba(...)`.
|
||||
- Text boxes must be sized to fit; static validation does not prove rendered wrapping.
|
||||
|
||||
## Shapes And Groups
|
||||
|
||||
- Geometry needs `slide:role="shape"` and a meaningful `slide:shape-type`.
|
||||
- Common geometry includes `rect`, `ellipse`, `circle`, `path`, and `line`.
|
||||
- Multi-element cards use `<g slide:role="group">`.
|
||||
- Children inside a group still keep their own `slide:role`.
|
||||
- A shape-with-text group is only for one geometry plus one text block. Cards with badges, icons, charts, or multiple text blocks must be regular groups.
|
||||
- Custom paths must declare a meaningful `slide:width` and `slide:height` that match the path's real bounding box.
|
||||
|
||||
## Lines
|
||||
|
||||
- Lines use `<line slide:role="shape" slide:shape-type="line">`.
|
||||
- Arrows use `slide:start-arrow` or `slide:end-arrow`.
|
||||
- SVG marker arrows are forbidden.
|
||||
|
||||
## Images
|
||||
|
||||
- Images use `<image slide:role="image" slide:shape-type="image" href="...">`.
|
||||
- Informational images preserve source aspect ratio.
|
||||
- Do not wrap a single image in a group unless it is truly part of a larger multi-element composition.
|
||||
- Borders and shadows belong on the image element itself when used.
|
||||
|
||||
## Charts
|
||||
|
||||
- Charts use `<rect slide:role="chart" href="..." x="..." y="..." width="..." height="...">`.
|
||||
- The rect is a chart placeholder; it is not a drawn rectangle.
|
||||
- Place charts at top level or inside `<g slide:role="group">`.
|
||||
- Preserve chart `href` verbatim unless the user asks to change chart data, type, emphasis, theme, or source.
|
||||
|
||||
## Notes
|
||||
|
||||
- Speaker notes are optional and do not render on canvas.
|
||||
- At most one `<slide:note>` may appear.
|
||||
- Notes contain direct paragraph children.
|
||||
|
||||
## Colors
|
||||
|
||||
- Use `rgb(...)`, `rgba(...)`, or `url(#id)`.
|
||||
- Do not use hex colors.
|
||||
- Do not use named colors.
|
||||
- Do not use `none` for `fill` or `stroke`; use `rgba(0,0,0,0)` for transparent fills.
|
||||
|
||||
## Animation
|
||||
|
||||
- Animation is part of delivery, not decoration.
|
||||
- Most slides should be static.
|
||||
- Presented decks may use progressive reveal for complex steps, charts, processes, timelines, or comparisons.
|
||||
- Self-read, formal, board, or consulting decks should read fully without clicks.
|
||||
- Use at most three builds on a slide.
|
||||
- Use one effect type per slide.
|
||||
- Animated elements need explicit `id`.
|
||||
- Animate top-level elements or top-level groups.
|
||||
- Use one deck-level page transition when needed; do not vary transition style slide by slide.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: svg_reference, svg_document_rules
|
||||
- Coverage mode: preserve hard SVG protocol requirements from the source while applying the CLI canvas adaptation to 960x540; visual guidance belongs in `visual-design.md`, not here.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"version": "svg-slides.split-manifest.v1",
|
||||
"source": "skills/lark-slides/references/svg-slides/source/full.debranded.md",
|
||||
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
|
||||
"source_role": "provenance_and_coverage_authority_not_default_runtime_context",
|
||||
"sections": [
|
||||
{"id": "title", "lines": [1, 1], "target": "README.md"},
|
||||
{"id": "system_prompt_workflow", "lines": [3, 196], "target": "workflow.md"},
|
||||
{"id": "svg_reference", "lines": [198, 865], "target": "protocol.md"},
|
||||
{"id": "resolve_design_brief", "lines": [867, 1080], "target": "design-brief.md"},
|
||||
{"id": "deck_design_reference_catalog", "lines": [1082, 1234], "target": "visual-design.md"},
|
||||
{"id": "slide_outline_tool", "lines": [1236, 1254], "target": "workflow.md"},
|
||||
{"id": "activate_slides_edit_tool", "lines": [1256, 1262], "target": "workflow.md"},
|
||||
{"id": "slides_edit_tool", "lines": [1264, 1281], "target": "authoring-rules.md"},
|
||||
{"id": "svg_document_rules", "lines": [1283, 1287], "target": "protocol.md"},
|
||||
{"id": "image_usage", "lines": [1289, 1291], "target": "authoring-rules.md"},
|
||||
{"id": "incremental_processing", "lines": [1293, 1331], "target": "workflow.md"},
|
||||
{"id": "finish_slides_edit_tool", "lines": [1333, 1339], "target": "validation.md"},
|
||||
{"id": "slide_organize_tool", "lines": [1341, 1347], "target": "editing-existing-decks.md"},
|
||||
{"id": "compute_custom_shape_bbox_tool", "lines": [1349, 1355], "target": "authoring-rules.md"},
|
||||
{"id": "generate_svg_chart_tool", "lines": [1357, 2356], "target": "chart-workflow.md"},
|
||||
{"id": "slides_convert_tool", "lines": [2358, 2395], "target": "editing-existing-decks.md"},
|
||||
{"id": "slides_parse_template_tool", "lines": [2397, 2420], "target": "editing-existing-decks.md"}
|
||||
]
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
# SVG Slides Validation
|
||||
|
||||
## Static Protocol Validator
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> --json
|
||||
```
|
||||
|
||||
The validator checks hard protocol rules:
|
||||
|
||||
- standalone SVG root;
|
||||
- `slide:role="slide"`;
|
||||
- required namespaces;
|
||||
- `viewBox="0 0 960 540"`;
|
||||
- background order;
|
||||
- forbidden style blocks and CSS classes;
|
||||
- forbidden text wrappers;
|
||||
- color syntax;
|
||||
- text `font-size` units;
|
||||
- line role and arrow semantics;
|
||||
- XML parseability.
|
||||
|
||||
The validator is a hard gate for publish-ready local bundles.
|
||||
|
||||
## Browser Text Boundary Check
|
||||
|
||||
Static XML cannot prove final rendered wrapping, CJK font fallback, or actual text height. Run browser text-boundary QA when Playwright is available:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> --out /tmp/svg-slides-text-bounds.json
|
||||
```
|
||||
|
||||
If Playwright is unavailable, the script exits 2 and explains the missing optional dependency.
|
||||
|
||||
## Bundle Manifest
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
|
||||
```
|
||||
|
||||
The bundle manifest records:
|
||||
|
||||
- protocol version;
|
||||
- title;
|
||||
- slide list;
|
||||
- validation receipt paths;
|
||||
- `publish_ready=true`;
|
||||
- `published=false`.
|
||||
|
||||
## Receipt Requirements
|
||||
|
||||
A local publish-ready bundle needs:
|
||||
|
||||
- `manifest.json`;
|
||||
- `receipts/validate_svg_deck.json`;
|
||||
- optional browser text-boundary receipt when browser QA ran;
|
||||
- slide files listed in deterministic order.
|
||||
|
||||
## What Passing Validation Does Not Prove
|
||||
|
||||
Passing validation does not prove visual excellence, source quality, chart truth, or backend acceptance. It proves that the generated local SVG files obey the hard protocol rules represented by the validator.
|
||||
|
||||
Always separate:
|
||||
|
||||
- protocol pass;
|
||||
- browser text-boundary pass;
|
||||
- visual design review;
|
||||
- live publish proof.
|
||||
|
||||
## Local Publish-Ready Bundle
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<deck title>"
|
||||
```
|
||||
|
||||
The command writes:
|
||||
|
||||
- `manifest.json`
|
||||
- `receipts/validate_svg_deck.json`
|
||||
|
||||
The manifest uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false
|
||||
}
|
||||
```
|
||||
|
||||
`publish_ready=true` means local static validation passed. It does not mean the deck was published to Lark Slides.
|
||||
|
||||
## Browser Text Boundary QA
|
||||
|
||||
When Playwright is available in the development environment, run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir> --out receipts/preview_text_bounds.json
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0`: no text-boundary problems.
|
||||
- `1`: rendered text overflow was detected.
|
||||
- `2`: the script could not run, for example Playwright is unavailable.
|
||||
|
||||
This browser check is a generation-quality gate. It is not a publish API proof.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: finish_slides_edit_tool
|
||||
- Coverage mode: preserve finish/validation gates and explicitly separate protocol pass from visual quality pass.
|
||||
@@ -1,74 +0,0 @@
|
||||
# SVG Slides Visual Design
|
||||
|
||||
## Typography
|
||||
|
||||
Use real font families that are likely to render. Keep a stable display/body pairing across the deck.
|
||||
|
||||
Titles, hero numbers, and key labels may use display fonts. Body text should use readable fonts. English and CJK decks need compatible font choices rather than generic fallback everywhere.
|
||||
|
||||
Do not switch typography per slide without a structural reason.
|
||||
|
||||
## Layout Freedom
|
||||
|
||||
SVG Slides gives full coordinate-level layout control. Use that control to encode the page's logic.
|
||||
|
||||
Start each slide by identifying the relationship in the content:
|
||||
|
||||
- comparison;
|
||||
- sequence;
|
||||
- timeline;
|
||||
- cycle;
|
||||
- hierarchy;
|
||||
- matrix or quadrant;
|
||||
- funnel;
|
||||
- part-to-whole;
|
||||
- cause to effect;
|
||||
- evidence to implication.
|
||||
|
||||
Then compose a bespoke structure using position, scale, alignment, grouping, flow direction, connectors, depth, and contrast. A layout invented for the slide's actual logic is better than a canned diagram.
|
||||
|
||||
## Visual Differentiation
|
||||
|
||||
Every substantive slide should have a visual idea: image, chart, diagram, process, comparison, spatial map, large number, table-like structure, or custom shape system.
|
||||
|
||||
Avoid repeating title-plus-bullets. Reuse deck-level motif and style, not the exact same page layout.
|
||||
|
||||
Cover, section divider, and closing pages are not exceptions. Unless the user explicitly requests no images, make these pages image-led with a high-impact hero image or generated visual. Text over imagery must use an intentional readability treatment, such as a translucent scrim or solid text zone, instead of relying on contrast by accident.
|
||||
|
||||
## Density
|
||||
|
||||
Density comes from audience and delivery mode. Split rather than cram when a slide needs more than one central idea.
|
||||
|
||||
Each slide should defend one central idea. Content slide titles should be declarative arguments, not topic labels. Cover, section, and closing slides can use shorter labels.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid:
|
||||
|
||||
- generic white slides with bullets only;
|
||||
- the same card grid on every page;
|
||||
- low-contrast text;
|
||||
- decorative lines crossing text;
|
||||
- filler agenda or Q&A pages in short decks;
|
||||
- placeholder images;
|
||||
- unverified data visualization;
|
||||
- text walls;
|
||||
- overusing animation.
|
||||
|
||||
## Remaining Human Judgment
|
||||
|
||||
Static validation proves protocol shape, not taste. A deck can pass validation and still be visually weak.
|
||||
|
||||
Review visual quality separately:
|
||||
|
||||
- Does the layout express the slide's logic?
|
||||
- Does each slide have a clear central claim?
|
||||
- Are data and claims source-backed?
|
||||
- Is typography intentional and consistent?
|
||||
- Is the page readable in a browser at expected size?
|
||||
- Does the deck vary composition while staying in one visual system?
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: deck_design_reference_catalog
|
||||
- Coverage mode: preserve visual quality rules and examples as generation guidance; do not collapse them into generic style advice.
|
||||
@@ -1,98 +0,0 @@
|
||||
# SVG Slides Workflow
|
||||
|
||||
## Layer Boundary
|
||||
|
||||
This workflow owns local SVG deck generation and local validation. It does not call live APIs, choose a PPE lane, or prove backend acceptance of the generated payload.
|
||||
|
||||
The output is a local publish-ready bundle: standalone SVG slide files, source notes, optional assets, validation receipts, and a manifest. It is not published.
|
||||
|
||||
## Phase 1: Understand Request
|
||||
|
||||
Decide whether the user wants a new deck, a continuation of an existing deck, a repair pass, or a visual-quality pass. Clarify only when the target file, target slides, audience, delivery mode, or requested outcome is genuinely ambiguous.
|
||||
|
||||
Audience means the final viewer, not the creator. A specific audience can drive density and evidence style directly. Generic labels such as "users", "clients", or "team" are not specific enough for broad generation unless the user asks not to be interrupted.
|
||||
|
||||
## Phase 2: Settle Goal Audience Delivery
|
||||
|
||||
Settle three values before designing slides:
|
||||
|
||||
- `goal`: what the presentation should make the viewer understand or decide.
|
||||
- `audience`: who will read or watch it.
|
||||
- `delivery_mode`: `presented` when a speaker talks over it, `self_read` when it must stand alone.
|
||||
|
||||
Do not ask the user to pick tone, palette, density, or style. Those are inferred later by the design brief.
|
||||
|
||||
## Phase 3: Build Source Material
|
||||
|
||||
Broad topic-only requests require real source material. Search snippets, memory, and internal knowledge are not enough.
|
||||
|
||||
Collect full source text before drafting claims. Save a local research file with data points, claims, caveats, and source references. Every important claim or number used later must be traceable from `slide_content.md` back to this source material.
|
||||
|
||||
## Phase 4: Resolve Design Brief
|
||||
|
||||
Create a design brief after goal, audience, delivery mode, language, page count, and source material are known.
|
||||
|
||||
The brief must include:
|
||||
|
||||
- `narrative_spine`: the sequence logic and discipline of the deck.
|
||||
- `depth`: altitude, density, include/exclude rules, and main points per slide.
|
||||
- `tone`: writing and evidence style.
|
||||
- `visual_system`: color, typography, layout, imagery, material, and decoration direction.
|
||||
|
||||
The design brief is authoritative for the generated deck. Do not override it with generic taste while authoring pages.
|
||||
|
||||
## Phase 5: Confirm Outline
|
||||
|
||||
For broad topics, create an actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise use 8-12 substantive slides for normal decks, unless the user explicitly asked for a short deck.
|
||||
|
||||
When the user gave a detailed outline, use it. When the user reorders, removes, adds, or rewrites slides, the user's outline wins over the brief's `narrative_spine`.
|
||||
|
||||
## Phase 6: Write slide_content
|
||||
|
||||
Write `slide_content.md` before SVG authoring.
|
||||
|
||||
`slide_content.md` records the structure, slide roles, key material, data points, claims, quotes, and source references. It does not lock exact final sentences, image paths, chart layout, or final page composition.
|
||||
|
||||
## Phase 7: Lock Visual Direction And Plan Visuals
|
||||
|
||||
Translate `visual_system` into concrete deck-level style:
|
||||
|
||||
- `aesthetic_direction`: the design language and mood from the brief.
|
||||
- `color_palette`: consistent deck palette, expressed later as `rgb(...)` / `rgba(...)`.
|
||||
- `typography`: a stable display/body pairing that matches the brief's category and treatment.
|
||||
- `visual_assets`: per-slide image and chart needs, including aspect ratio and placement intent.
|
||||
|
||||
Unless the user explicitly requests no images, cover, section divider, and closing pages default to a high-impact hero image or generated visual. Record the intended asset, crop/aspect ratio, placement, and text-readability overlay treatment in `visual_assets`; do not leave these page roles as text-only by default.
|
||||
|
||||
Plan charts before writing slides. Any real quantitative series that supports a slide's point should use the chart workflow rather than a hand-drawn fake chart.
|
||||
|
||||
## Phase 8: Author SVG Pages
|
||||
|
||||
Each page is a complete standalone SVG document. Compose freely for the page's content logic. Do not stamp out a fixed template pattern.
|
||||
|
||||
For each page, record authoring intent before writing:
|
||||
|
||||
- the central idea;
|
||||
- the layout relationship being encoded;
|
||||
- visual assets used;
|
||||
- animation decision, or `static`;
|
||||
- expected validation risks.
|
||||
|
||||
Do not regenerate the whole deck structure after slide files have been authored. Add or remove pages through the existing-deck workflow.
|
||||
|
||||
## Output Bundle
|
||||
|
||||
The local bundle should contain:
|
||||
|
||||
- `slides/*.svg`: one standalone SVG slide per page;
|
||||
- `slide_content.md`: source-backed content plan;
|
||||
- `research_notes.md` when source material was gathered;
|
||||
- `resources/` for chart/image sidecars;
|
||||
- `manifest.json` from `svg_slides_bundle.mjs`;
|
||||
- `receipts/validate_svg_deck.json` from the static validator;
|
||||
- optional browser text-boundary receipt.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: system_prompt_workflow, slide_outline_tool, activate_slides_edit_tool, incremental_processing
|
||||
- Coverage mode: preserve workflow semantics from the source while replacing product-specific tool names with local generation stages.
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const outIndex = args.indexOf("--out");
|
||||
const outPath = outIndex >= 0 ? args[outIndex + 1] : "";
|
||||
|
||||
if (!targetArg) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> [--out <json-path>]");
|
||||
}
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import("playwright"));
|
||||
} catch {
|
||||
fail("playwright is not installed; install it in a dev environment before browser text-boundary QA", 2);
|
||||
}
|
||||
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort();
|
||||
if (!slideFiles.length) {
|
||||
fail(`No .svg files found in ${slidesDir}`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 });
|
||||
const results = [];
|
||||
|
||||
for (const file of slideFiles) {
|
||||
const abs = path.join(slidesDir, file);
|
||||
const svg = fs.readFileSync(abs, "utf8");
|
||||
await page.setContent(`<!doctype html><html><body style="margin:0">${svg}</body></html>`, { waitUntil: "load" });
|
||||
const problems = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll("foreignObject")].flatMap((node, index) => {
|
||||
if (node.getAttribute("slide:role") !== "shape" || node.getAttribute("slide:shape-type") !== "text") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const box = node.getBoundingClientRect();
|
||||
const children = [...node.children];
|
||||
if (!children.length) {
|
||||
return [{ index: index + 1, reason: "empty_text_object" }];
|
||||
}
|
||||
|
||||
return children.map((child) => {
|
||||
const childBox = child.getBoundingClientRect();
|
||||
const overflowX = childBox.left < box.left - 0.5 || childBox.right > box.right + 0.5;
|
||||
const overflowY = childBox.top < box.top - 0.5 || childBox.bottom > box.bottom + 0.5;
|
||||
if (!overflowX && !overflowY) return null;
|
||||
return {
|
||||
index: index + 1,
|
||||
reason: "text_bounds_overflow",
|
||||
box: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
childBox: { x: childBox.x, y: childBox.y, width: childBox.width, height: childBox.height }
|
||||
};
|
||||
}).filter(Boolean);
|
||||
});
|
||||
});
|
||||
results.push({ file: path.relative(process.cwd(), abs), problemCount: problems.length, problems });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const problemCount = results.reduce((sum, item) => sum + item.problemCount, 0);
|
||||
const report = { status: problemCount === 0 ? "passed" : "failed", problemCount, results };
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (outPath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
||||
fs.writeFileSync(outPath, json);
|
||||
}
|
||||
process.stdout.write(json);
|
||||
process.exit(problemCount === 0 ? 0 : 1);
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const titleIndex = args.indexOf("--title");
|
||||
const title = titleIndex >= 0 ? args[titleIndex + 1] : "";
|
||||
|
||||
if (!deckArg || !title) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title <title>");
|
||||
}
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
|
||||
const validate = spawnSync("node", [validator, root, "--json"], { encoding: "utf8" });
|
||||
if (!validate.stdout.trim()) {
|
||||
process.stderr.write(validate.stderr);
|
||||
process.exit(validate.status || 1);
|
||||
}
|
||||
|
||||
const receipt = JSON.parse(validate.stdout);
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
if (receipt.totalErrors !== 0) {
|
||||
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort();
|
||||
|
||||
const pages = slideFiles.map((file, index) => {
|
||||
const abs = path.join(slidesDir, file);
|
||||
const raw = fs.readFileSync(abs);
|
||||
return {
|
||||
id: path.basename(file, ".svg"),
|
||||
index: index + 1,
|
||||
file: path.relative(root, abs).split(path.sep).join("/"),
|
||||
sha256: crypto.createHash("sha256").update(raw).digest("hex")
|
||||
};
|
||||
});
|
||||
|
||||
const manifest = {
|
||||
version: "svglide.manifest.v1",
|
||||
protocol: "svg-slides.v1",
|
||||
title,
|
||||
size: { width: 960, height: 540 },
|
||||
publish_ready: true,
|
||||
published: false,
|
||||
pages,
|
||||
receipts: {
|
||||
validate_svg_deck: "receipts/validate_svg_deck.json"
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ ok: true, manifest: path.join(root, "manifest.json"), pages: pages.length }, null, 2));
|
||||
@@ -1,49 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_bundle.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-bundle-"));
|
||||
fs.mkdirSync(path.join(root, "slides"));
|
||||
return root;
|
||||
}
|
||||
|
||||
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="bundle_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Bundle</p>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
test("bundle builder writes manifest and validation receipt", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
const result = spawnSync("node", [script, root, "--title", "Bundle Test"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.version, "svglide.manifest.v1");
|
||||
assert.equal(manifest.protocol, "svg-slides.v1");
|
||||
assert.equal(manifest.title, "Bundle Test");
|
||||
assert.deepEqual(manifest.size, { width: 960, height: 540 });
|
||||
assert.equal(manifest.publish_ready, true);
|
||||
assert.equal(manifest.published, false);
|
||||
assert.equal(manifest.pages.length, 1);
|
||||
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
|
||||
assert.equal(receipt.totalErrors, 0);
|
||||
});
|
||||
|
||||
test("bundle builder rejects invalid SVG deck", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace("rgba(255,255,255,1)", "#fff"));
|
||||
const result = spawnSync("node", [script, root, "--title", "Invalid"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /not publish-ready/);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "validate_svg_deck.json")), true);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const rootArg = process.argv.find((arg) => !arg.startsWith("--") && arg !== process.argv[1] && arg !== process.argv[0]);
|
||||
const root = path.resolve(rootArg || "skills/lark-slides/references/svg-slides");
|
||||
const json = process.argv.includes("--json");
|
||||
const manifestPath = path.join(root, "source", "split-manifest.json");
|
||||
|
||||
function readText(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function coverageBlock(markdown) {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => line.trim() === "## Source Coverage");
|
||||
if (start === -1) return "";
|
||||
const block = [];
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (/^#{1,2}\s+/.test(lines[i])) break;
|
||||
block.push(lines[i]);
|
||||
}
|
||||
return block.join("\n");
|
||||
}
|
||||
|
||||
function coverageIds(block) {
|
||||
const match = block.match(/^- Covers manifest sections:\s*(.+)$/m);
|
||||
if (!match) return [];
|
||||
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
errors.push(`missing manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const manifest = errors.length === 0 ? JSON.parse(readText(manifestPath)) : { sections: [] };
|
||||
const sectionsById = new Map(manifest.sections.map((section) => [section.id, section]));
|
||||
const seen = new Map();
|
||||
|
||||
if (fs.existsSync(root)) {
|
||||
for (const entry of fs.readdirSync(root)) {
|
||||
if (!entry.endsWith(".md")) continue;
|
||||
const filePath = path.join(root, entry);
|
||||
const block = coverageBlock(readText(filePath));
|
||||
if (!block) {
|
||||
errors.push(`${entry}: missing ## Source Coverage`);
|
||||
continue;
|
||||
}
|
||||
const ids = coverageIds(block);
|
||||
if (ids.length === 0) {
|
||||
errors.push(`${entry}: missing "- Covers manifest sections:" line`);
|
||||
continue;
|
||||
}
|
||||
for (const id of ids) {
|
||||
const section = sectionsById.get(id);
|
||||
if (!section) {
|
||||
errors.push(`${entry}: unknown manifest section "${id}"`);
|
||||
continue;
|
||||
}
|
||||
if (section.target !== entry) {
|
||||
errors.push(`${entry}: section "${id}" belongs to ${section.target}`);
|
||||
}
|
||||
const files = seen.get(id) || [];
|
||||
files.push(entry);
|
||||
seen.set(id, files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of manifest.sections) {
|
||||
const files = seen.get(section.id) || [];
|
||||
if (files.length === 0) {
|
||||
errors.push(`${section.id}: not covered by ${section.target}`);
|
||||
}
|
||||
if (files.length > 1) {
|
||||
errors.push(`${section.id}: covered multiple times by ${files.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
sectionCount: manifest.sections.length,
|
||||
coveredCount: seen.size,
|
||||
errors
|
||||
};
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else if (errors.length === 0) {
|
||||
console.log(`Source coverage OK: ${report.coveredCount}/${report.sectionCount} sections`);
|
||||
} else {
|
||||
console.error(`Source coverage failed: ${errors.length} errors`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
}
|
||||
|
||||
process.exit(errors.length === 0 ? 0 : 1);
|
||||
@@ -1,55 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs");
|
||||
|
||||
function tempRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-coverage-"));
|
||||
fs.mkdirSync(path.join(root, "source"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeManifest(root, sections) {
|
||||
fs.writeFileSync(path.join(root, "source", "split-manifest.json"), JSON.stringify({
|
||||
version: "svg-slides.split-manifest.v1",
|
||||
source: "source/full.debranded.md",
|
||||
sections
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function run(root) {
|
||||
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("passes when each manifest section is covered by its target file", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "workflow", lines: [1, 10], target: "workflow.md" }]);
|
||||
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: workflow\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).errors.length, 0);
|
||||
});
|
||||
|
||||
test("fails when a section is missing from Source Coverage", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "protocol", lines: [1, 10], target: "protocol.md" }]);
|
||||
fs.writeFileSync(path.join(root, "protocol.md"), "# Protocol\n\n## Source Coverage\n\n- Covers manifest sections: other\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.ok(report.errors.some((error) => error.includes("unknown manifest section")));
|
||||
assert.ok(report.errors.some((error) => error.includes("not covered")));
|
||||
});
|
||||
|
||||
test("fails when a section is declared by the wrong target file", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "visual", lines: [1, 10], target: "visual-design.md" }]);
|
||||
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: visual\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.ok(JSON.parse(result.stdout).errors.some((error) => error.includes("belongs to visual-design.md")));
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> [--json]");
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const json = args.includes("--json");
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (!targetArg) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
console.error(`Slides directory not found: ${slidesDir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => path.join(slidesDir, file));
|
||||
|
||||
if (!slideFiles.length) {
|
||||
console.error(`No .svg files found in: ${slidesDir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function commandExists(name) {
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function checkXml(file, errors) {
|
||||
if (!commandExists("xmllint")) {
|
||||
errors.push({
|
||||
rule: "xml.valid",
|
||||
severity: "warn",
|
||||
message: "xmllint is unavailable; XML parser check skipped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = spawnSync("xmllint", ["--noout", file], { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
errors.push({
|
||||
rule: "xml.valid",
|
||||
severity: "error",
|
||||
message: (result.stderr || result.stdout || "xmllint failed").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function firstElementAfterDefs(svg) {
|
||||
const rootOpen = svg.match(/<svg\b[^>]*>/);
|
||||
if (!rootOpen) return null;
|
||||
let inner = svg.slice(rootOpen.index + rootOpen[0].length, svg.lastIndexOf("</svg>")).trim();
|
||||
if (inner.startsWith("<defs")) {
|
||||
const end = inner.indexOf("</defs>");
|
||||
if (end === -1) return null;
|
||||
inner = inner.slice(end + "</defs>".length).trim();
|
||||
}
|
||||
return inner.match(/^<([a-zA-Z][\w:-]*)\b([^>]*)>/)?.[0] || null;
|
||||
}
|
||||
|
||||
function stripDefs(svg) {
|
||||
return svg.replace(/<defs\b[\s\S]*?<\/defs>/g, "");
|
||||
}
|
||||
|
||||
function attrValue(tag, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return tag.match(new RegExp(`${escaped}="([^"]*)"`))?.[1] || "";
|
||||
}
|
||||
|
||||
function isColorValueAllowed(value) {
|
||||
return /^(rgba?\([^)]*\)|url\(#[-\w]+\))$/.test(value.trim());
|
||||
}
|
||||
|
||||
function checkSlide(file) {
|
||||
const rel = path.relative(process.cwd(), file);
|
||||
const svg = fs.readFileSync(file, "utf8");
|
||||
const errors = [];
|
||||
|
||||
checkXml(file, errors);
|
||||
|
||||
const root = svg.match(/<svg\b[^>]*>/)?.[0] || "";
|
||||
if (!root) {
|
||||
errors.push({ rule: "svg.root", severity: "error", message: "missing <svg> root" });
|
||||
} else {
|
||||
if (!/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.xmlns", severity: "error", message: "missing SVG namespace" });
|
||||
}
|
||||
if (!/xmlns:slide="https:\/\/slides\.bytedance\.com\/ns"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.slide-xmlns", severity: "error", message: "missing slide namespace" });
|
||||
}
|
||||
if (!/slide:role="slide"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.slide-role", severity: "error", message: "root must have slide:role=\"slide\"" });
|
||||
}
|
||||
if (!/id="[^"]+"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.id", severity: "error", message: "root must have id" });
|
||||
}
|
||||
if (!/viewBox="0 0 960 540"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.viewBox", severity: "error", message: "expected viewBox=\"0 0 960 540\"" });
|
||||
}
|
||||
}
|
||||
|
||||
if (/<presentation\b/.test(svg)) {
|
||||
errors.push({ rule: "svg.no-presentation-wrapper", severity: "error", message: "single slide file must not wrap with <presentation>" });
|
||||
}
|
||||
|
||||
const first = firstElementAfterDefs(svg);
|
||||
const backgroundCount = (svg.match(/slide:role="background"/g) || []).length;
|
||||
if (backgroundCount !== 1) {
|
||||
errors.push({ rule: "background.count", severity: "error", message: `expected exactly one background, found ${backgroundCount}` });
|
||||
}
|
||||
if (!first || !/^(<rect\b|<image\b)/.test(first) || !/slide:role="background"/.test(first)) {
|
||||
errors.push({ rule: "background.first-child", severity: "error", message: "first rendered child after optional <defs> must be the background" });
|
||||
}
|
||||
|
||||
const bodyNoDefs = stripDefs(svg);
|
||||
const forbidden = [
|
||||
{ rule: "forbid.style-block", re: /<style\b/, message: "slide SVG must not rely on <style> blocks" },
|
||||
{ rule: "forbid.css-class", re: /\bclass="/, message: "slide SVG must not rely on CSS classes" },
|
||||
{ rule: "forbid.div-wrapper", re: /<div\b/, message: "text foreignObject must not contain <div>" },
|
||||
{ rule: "forbid.section-wrapper", re: /<section\b/, message: "text foreignObject must not contain <section>" },
|
||||
{ rule: "forbid.svg-text", re: /<text\b/, message: "use foreignObject rich text, not SVG <text>" },
|
||||
{ rule: "forbid.svg-marker", re: /\bmarker-(start|end|mid)=|<marker\b/, message: "line arrowheads must use slide:* arrow attributes, not SVG marker" },
|
||||
{ rule: "forbid.legacy-fontSize", re: /\bfontSize="/, message: "text visual properties must be in style, not legacy fontSize attribute" },
|
||||
{ rule: "forbid.legacy-bold", re: /\bbold="/, message: "text visual properties must be in style, not legacy bold attribute" },
|
||||
];
|
||||
for (const item of forbidden) {
|
||||
if (item.re.test(bodyNoDefs)) {
|
||||
errors.push({ rule: item.rule, severity: "error", message: item.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of bodyNoDefs.matchAll(/\b(fill|stroke|stop-color)="([^"]+)"/g)) {
|
||||
const [, attr, value] = match;
|
||||
if (!isColorValueAllowed(value)) {
|
||||
errors.push({
|
||||
rule: "color.attr",
|
||||
severity: "error",
|
||||
message: `${attr} must be rgb(...), rgba(...), or url(#...); got ${JSON.stringify(value)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const match of bodyNoDefs.matchAll(/(?:^|;)\s*color\s*:\s*([^;"]+)/g)) {
|
||||
const value = match[1].trim();
|
||||
if (!/^rgba?\([^)]*\)$/.test(value)) {
|
||||
errors.push({
|
||||
rule: "color.css",
|
||||
severity: "error",
|
||||
message: `CSS color must be rgb(...) or rgba(...); got ${JSON.stringify(value)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const foreignObjects = [...svg.matchAll(/<foreignObject\b([^>]*)>([\s\S]*?)<\/foreignObject>/g)];
|
||||
for (const [index, match] of foreignObjects.entries()) {
|
||||
const attrText = match[1];
|
||||
const inner = match[2].trim();
|
||||
const label = `foreignObject #${index + 1}`;
|
||||
const isTextObject = /slide:role="shape"/.test(attrText) && /slide:shape-type="text"/.test(attrText);
|
||||
if (!isTextObject) continue;
|
||||
|
||||
for (const attr of ["x", "y", "width", "height"]) {
|
||||
if (!new RegExp(`\\b${attr}="[-0-9.]+`).test(attrText)) {
|
||||
errors.push({ rule: "text.geometry", severity: "error", message: `${label} missing numeric ${attr}` });
|
||||
}
|
||||
}
|
||||
|
||||
const styleText = attrValue(match[0], "style");
|
||||
if (!/font-size:\s*\d+(?:\.\d+)?px/.test(styleText)) {
|
||||
errors.push({ rule: "text.style.font-size", severity: "error", message: `${label} missing font-size with px suffix in style` });
|
||||
}
|
||||
if (!/color:\s*rgba?\(/.test(styleText)) {
|
||||
errors.push({ rule: "text.style.color", severity: "error", message: `${label} missing rgb/rgba color in style` });
|
||||
}
|
||||
|
||||
if (!/^<(p|ul|ol|h1|h2|h3|small)\b/.test(inner)) {
|
||||
errors.push({
|
||||
rule: "text.direct-child",
|
||||
severity: "error",
|
||||
message: `${label} first direct child must be p/ul/ol/h1/h2/h3/small, got ${inner.slice(0, 40) || "empty"}`,
|
||||
});
|
||||
}
|
||||
if (/<(div|section)\b/.test(inner)) {
|
||||
errors.push({ rule: "text.no-wrapper", severity: "error", message: `${label} contains an invalid wrapper element` });
|
||||
}
|
||||
if (/^([^<]|\s)+$/.test(inner)) {
|
||||
errors.push({ rule: "text.no-bare-text", severity: "error", message: `${label} contains bare text instead of xhtml children` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of bodyNoDefs.matchAll(/<line\b([^>]*)>/g)) {
|
||||
const attrs = match[1];
|
||||
if (!/slide:role="shape"/.test(attrs) || !/slide:shape-type="line"/.test(attrs)) {
|
||||
errors.push({ rule: "line.role", severity: "error", message: "line must carry slide:role=\"shape\" and slide:shape-type=\"line\"" });
|
||||
}
|
||||
if (!/\bstroke="rgba?\(/.test(attrs)) {
|
||||
errors.push({ rule: "line.stroke", severity: "error", message: "line must have rgb/rgba stroke" });
|
||||
}
|
||||
}
|
||||
|
||||
return { file: rel, errorCount: errors.filter((item) => item.severity === "error").length, errors };
|
||||
}
|
||||
|
||||
const results = slideFiles.map(checkSlide);
|
||||
const totalErrors = results.reduce((sum, result) => sum + result.errorCount, 0);
|
||||
const report = {
|
||||
target: path.relative(process.cwd(), target),
|
||||
slidesDir: path.relative(process.cwd(), slidesDir),
|
||||
slideCount: slideFiles.length,
|
||||
totalErrors,
|
||||
results,
|
||||
};
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(`SVG deck validation: ${report.target}`);
|
||||
console.log(`Slides: ${report.slideCount}`);
|
||||
console.log(`Errors: ${report.totalErrors}`);
|
||||
for (const result of results) {
|
||||
const status = result.errorCount ? "FAIL" : "PASS";
|
||||
console.log(`\n[${status}] ${result.file}`);
|
||||
for (const error of result.errors) {
|
||||
if (error.severity === "warn") {
|
||||
console.log(` WARN ${error.rule}: ${error.message}`);
|
||||
} else {
|
||||
console.log(` ${error.rule}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(totalErrors ? 1 : 0);
|
||||
@@ -1,75 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-validator-"));
|
||||
fs.mkdirSync(path.join(root, "slides"));
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeSlide(root, name, body) {
|
||||
fs.writeFileSync(path.join(root, "slides", name), body);
|
||||
}
|
||||
|
||||
function runValidator(root) {
|
||||
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="valid" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Valid</p>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
test("valid SVG deck passes", () => {
|
||||
const root = tempDeck();
|
||||
writeSlide(root, "slide_01.svg", validSlide);
|
||||
|
||||
const result = runValidator(root);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.slideCount, 1);
|
||||
assert.equal(report.totalErrors, 0);
|
||||
});
|
||||
|
||||
test("invalid SVG deck reports protocol errors", () => {
|
||||
const root = tempDeck();
|
||||
writeSlide(root, "slide_01.svg", `<svg xmlns="http://www.w3.org/2000/svg" id="bad" viewBox="0 0 960 540">
|
||||
<style>.t{fill:red}</style>
|
||||
<rect width="960" height="540" fill="#fff"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="300" height="80" style="font-size:32;color:#111">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"><p>Bad</p></div>
|
||||
</foreignObject>
|
||||
</svg>`);
|
||||
|
||||
const result = runValidator(root);
|
||||
assert.equal(result.status, 1);
|
||||
|
||||
const report = JSON.parse(result.stdout);
|
||||
const rules = report.results.flatMap((item) => item.errors.map((error) => error.rule));
|
||||
assert.ok(rules.includes("svg.root.slide-xmlns"));
|
||||
assert.ok(rules.includes("svg.root.slide-role"));
|
||||
assert.ok(rules.includes("background.first-child"));
|
||||
assert.ok(rules.includes("forbid.style-block"));
|
||||
assert.ok(rules.includes("forbid.div-wrapper"));
|
||||
assert.ok(rules.includes("color.attr"));
|
||||
assert.ok(rules.includes("text.style.font-size"));
|
||||
assert.ok(rules.includes("text.style.color"));
|
||||
});
|
||||
|
||||
test("examples directory remains protocol-valid", () => {
|
||||
const examples = path.resolve("skills/lark-slides/references/svg-slides/examples");
|
||||
const result = spawnSync("node", [script, examples, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.slideCount, 3);
|
||||
assert.equal(report.totalErrors, 0);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.2
|
||||
version: 1.0.1
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:上传文件到知识库节点下(走 lark-drive)、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base)。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -34,8 +34,6 @@ metadata:
|
||||
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
|
||||
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token;如果用户给的是 docx/sheet/base URL,先用 `wiki +node-get --node-token <url>` 解析出 `node_token`。
|
||||
- `wiki +node-list` 命中 `invalid_parameters`、`not_found`、`permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门 / 应用”四类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 / 应用 + 添加成员”:先解析对应 ID,再执行 `wiki +member-add`。
|
||||
|
||||
@@ -11,9 +11,6 @@ lark-cli wiki +node-list --space-id <SPACE_ID>
|
||||
# Drill into a sub-directory (still single page by default)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
|
||||
|
||||
# Drill with a wiki URL (CLI normalizes /wiki/<token> to node_token)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token "https://feishu.cn/wiki/wikcn_xxx"
|
||||
|
||||
# Personal document library (user identity only)
|
||||
lark-cli wiki +node-list --space-id my_library --as user
|
||||
|
||||
@@ -34,8 +31,8 @@ lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--space-id` | string | **Yes** | — | Numeric wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent wiki node token, or a `/wiki/<token>` URL; omit to list the space root |
|
||||
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
|
||||
| `--page-size` | int | No | 50 | Page size, 1-50 |
|
||||
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
|
||||
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
|
||||
@@ -85,10 +82,6 @@ lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikc
|
||||
## Notes
|
||||
|
||||
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
|
||||
- `--space-id` is a numeric wiki `space_id`. Do not pass a wiki URL, wiki node token, document token, or title. Use `lark-cli wiki +space-list --as user` to discover it.
|
||||
- `--parent-node-token` must resolve to a wiki node token. If you have a docx/sheet/base/file URL, first run `lark-cli wiki +node-get --node-token <url>` and use the returned `node_token`.
|
||||
- Treat `invalid_parameters` (`space_id is not int`, `invalid page_token`), `not_found` (`node not found by parent node token`), and `permission_denied` as terminal for the current arguments. Fix the argument or permission before retrying.
|
||||
- For `rate_limit`, stop immediate retries and retry later with exponential backoff or a smaller `--page-limit`.
|
||||
|
||||
## Required Scope
|
||||
|
||||
|
||||
@@ -24,17 +24,7 @@ import (
|
||||
const EnvBinaryPath = "LARK_CLI_BIN"
|
||||
const projectRootMarkerDir = "tests"
|
||||
const cliBinaryName = "lark-cli"
|
||||
|
||||
const (
|
||||
// CleanupTimeout is the outer teardown budget. Keep it above any
|
||||
// per-resource wait so cleanup command retries still have room to run.
|
||||
CleanupTimeout = 60 * time.Second
|
||||
|
||||
defaultRetryAttempts = 4
|
||||
defaultRetryInitialDelay = time.Second
|
||||
defaultRetryMaxDelay = 6 * time.Second
|
||||
defaultRetryBackoffMultiple = 2
|
||||
)
|
||||
const CleanupTimeout = 30 * time.Second
|
||||
|
||||
func SkipWithoutUserToken(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -112,34 +102,6 @@ type Result struct {
|
||||
RunErr error
|
||||
}
|
||||
|
||||
type cleanupWarningError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *cleanupWarningError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *cleanupWarningError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// CleanupWarning marks a cleanup verification issue as non-fatal after the
|
||||
// destructive cleanup command itself has already succeeded.
|
||||
func CleanupWarning(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &cleanupWarningError{err: err}
|
||||
}
|
||||
|
||||
// IsCleanupWarning reports whether err should be logged without failing the
|
||||
// parent test.
|
||||
func IsCleanupWarning(err error) bool {
|
||||
var warning *cleanupWarningError
|
||||
return errors.As(err, &warning)
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for flaky external API calls.
|
||||
type RetryOptions struct {
|
||||
Attempts int
|
||||
@@ -149,25 +111,8 @@ type RetryOptions struct {
|
||||
ShouldRetry func(*Result) bool
|
||||
}
|
||||
|
||||
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
|
||||
// or verification checks.
|
||||
type WaitOptions struct {
|
||||
Timeout time.Duration
|
||||
Interval time.Duration
|
||||
TimeoutError func() error
|
||||
}
|
||||
|
||||
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
|
||||
// Service errors that return {"error":{"retryable":true}} are retried with
|
||||
// bounded exponential backoff so individual tests do not need to remember
|
||||
// RunCmdWithRetry for normal transient server contention.
|
||||
func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
return RunCmdWithRetry(ctx, req, RetryOptions{
|
||||
ShouldRetry: ResultHasRetryableError,
|
||||
})
|
||||
}
|
||||
|
||||
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
|
||||
binaryPath, err := ResolveBinaryPath(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -241,16 +186,16 @@ func buildCommandEnv(req Request) []string {
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
opts.Attempts = defaultRetryAttempts
|
||||
opts.Attempts = 4
|
||||
}
|
||||
if opts.InitialDelay <= 0 {
|
||||
opts.InitialDelay = defaultRetryInitialDelay
|
||||
opts.InitialDelay = 1 * time.Second
|
||||
}
|
||||
if opts.MaxDelay <= 0 {
|
||||
opts.MaxDelay = defaultRetryMaxDelay
|
||||
opts.MaxDelay = 6 * time.Second
|
||||
}
|
||||
if opts.BackoffMultiple <= 1 {
|
||||
opts.BackoffMultiple = defaultRetryBackoffMultiple
|
||||
opts.BackoffMultiple = 2
|
||||
}
|
||||
if opts.ShouldRetry == nil {
|
||||
opts.ShouldRetry = func(result *Result) bool {
|
||||
@@ -261,7 +206,7 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
|
||||
delay := opts.InitialDelay
|
||||
var lastResult *Result
|
||||
for attempt := 1; attempt <= opts.Attempts; attempt++ {
|
||||
result, err := runCmdOnce(ctx, req)
|
||||
result, err := RunCmd(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -289,63 +234,6 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
|
||||
return lastResult, nil
|
||||
}
|
||||
|
||||
// ResultHasRetryableError reports whether lark-cli returned a structured
|
||||
// service error with error.retryable=true in either output stream.
|
||||
func ResultHasRetryableError(result *Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
|
||||
}
|
||||
|
||||
func rawHasRetryableError(raw string) bool {
|
||||
payload := extractJSONPayload(raw)
|
||||
if payload == "" {
|
||||
return false
|
||||
}
|
||||
return gjson.Get(payload, "error.retryable").Bool()
|
||||
}
|
||||
|
||||
// WaitForCondition polls condition until it returns true, an error, the context
|
||||
// is canceled, or the configured timeout expires.
|
||||
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
|
||||
if condition == nil {
|
||||
return errors.New("wait condition is nil")
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = CleanupTimeout
|
||||
}
|
||||
if opts.Interval <= 0 {
|
||||
opts.Interval = time.Second
|
||||
}
|
||||
|
||||
deadline := time.NewTimer(opts.Timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(opts.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
done, err := condition()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
if opts.TimeoutError != nil {
|
||||
return opts.TimeoutError()
|
||||
}
|
||||
return fmt.Errorf("condition still false after %s", opts.Timeout)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
|
||||
func GenerateSuffix() string {
|
||||
now := time.Now().UTC()
|
||||
@@ -363,10 +251,6 @@ func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err
|
||||
parentT.Helper()
|
||||
|
||||
if err != nil {
|
||||
if IsCleanupWarning(err) {
|
||||
parentT.Logf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
parentT.Errorf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
@@ -387,11 +271,26 @@ func isCleanupSuppressedResult(result *Result) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := extractJSONPayload(result.Stdout)
|
||||
if payload == "" {
|
||||
payload = extractJSONPayload(result.Stderr)
|
||||
raw := strings.TrimSpace(result.Stdout)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
if payload == "" {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -407,32 +306,6 @@ func isCleanupSuppressedResult(result *Result) bool {
|
||||
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
|
||||
}
|
||||
|
||||
func extractJSONPayload(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if gjson.Valid(raw) {
|
||||
return raw
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
return ""
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
|
||||
func ResolveBinaryPath(req Request) (string, error) {
|
||||
if req.BinaryPath != "" {
|
||||
|
||||
@@ -5,12 +5,10 @@ package clie2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -225,88 +223,6 @@ func TestRunCmd(t *testing.T) {
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmd(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"fail-once-retryable", statePath},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2\n", string(countBytes))
|
||||
})
|
||||
|
||||
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmd(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"always-non-retryable", statePath},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 1)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1\n", string(countBytes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCmdWithRetry(t *testing.T) {
|
||||
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmdWithRetry(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"fail-once-retryable", statePath},
|
||||
}, RetryOptions{
|
||||
Attempts: 1,
|
||||
InitialDelay: time.Millisecond,
|
||||
MaxDelay: time.Millisecond,
|
||||
ShouldRetry: ResultHasRetryableError,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 1)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1\n", string(countBytes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWaitForCondition(t *testing.T) {
|
||||
t.Run("polls until condition succeeds", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := WaitForCondition(context.Background(), WaitOptions{
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
}, func() (bool, error) {
|
||||
attempts++
|
||||
return attempts == 2, nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, attempts)
|
||||
})
|
||||
|
||||
t.Run("returns custom timeout error", func(t *testing.T) {
|
||||
wantErr := errors.New("still visible")
|
||||
err := WaitForCondition(context.Background(), WaitOptions{
|
||||
Timeout: time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
TimeoutError: func() error { return wantErr },
|
||||
}, func() (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
|
||||
assert.ErrorIs(t, err, wantErr)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeCLI struct {
|
||||
@@ -344,35 +260,6 @@ if [ "$1" = "emit-stdin" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "fail-once-retryable" ]; then
|
||||
state="$2"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$state"
|
||||
if [ "$count" -eq 1 ]; then
|
||||
echo "Deleting folder fake..." >&2
|
||||
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo '{"ok":true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "always-non-retryable" ]; then
|
||||
state="$2"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$state"
|
||||
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit_code=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "doxcnDryRunCompat",
|
||||
"--api-version", "v1",
|
||||
"--api-version", "legacy",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -21,7 +21,7 @@
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -61,6 +61,96 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiDryRunExport",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "wiki-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
|
||||
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "wikiDryRunExport",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
driveDeleteVisibilityTimeout = 30 * time.Second
|
||||
driveDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
var driveDeleteVisibilityWait = clie2e.WaitOptions{
|
||||
// This wait only covers the post-delete visibility lag after Drive accepts
|
||||
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
|
||||
Timeout: driveDeleteVisibilityTimeout,
|
||||
Interval: driveDeleteVisibilityPoll,
|
||||
}
|
||||
|
||||
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
|
||||
// deletes it during parent cleanup.
|
||||
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
|
||||
@@ -72,18 +60,14 @@ func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
|
||||
// returned a suppressed not_found or partial API error but the resource still
|
||||
// exists.
|
||||
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
|
||||
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
|
||||
}
|
||||
|
||||
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
|
||||
if defaultAs == "" {
|
||||
defaultAs = "bot"
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
}, clie2e.RetryOptions{})
|
||||
if deleteErr != nil || deleteResult == nil {
|
||||
return deleteResult, deleteErr
|
||||
}
|
||||
@@ -98,21 +82,35 @@ func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
|
||||
}
|
||||
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
|
||||
}
|
||||
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
|
||||
return deleteResult, clie2e.CleanupWarning(
|
||||
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
|
||||
)
|
||||
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
|
||||
return deleteResult, err
|
||||
}
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
|
||||
opts.TimeoutError = func() error {
|
||||
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
|
||||
func WaitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) error {
|
||||
deadline := time.NewTimer(20 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
deleted, err := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("drive resource %s/%s still exists after delete", docType, token)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
})
|
||||
}
|
||||
|
||||
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {
|
||||
|
||||
@@ -5,13 +5,8 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -21,59 +16,3 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
|
||||
require.NotEmpty(t, folderToken)
|
||||
return folderToken
|
||||
}
|
||||
|
||||
func TestDeleteDriveResourceAndVerify(t *testing.T) {
|
||||
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
|
||||
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
})
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 0, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
})
|
||||
|
||||
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
|
||||
|
||||
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
assert.Contains(t, err.Error(), "still exists after delete failed")
|
||||
})
|
||||
}
|
||||
|
||||
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
|
||||
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
|
||||
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
|
||||
exit "$FAKE_DRIVE_DELETE_EXIT"
|
||||
fi
|
||||
echo '{"ok":true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected fake CLI args: $*" >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
|
||||
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Mail CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 65 leaf commands
|
||||
- Covered: 16
|
||||
- Coverage: 24.6%
|
||||
- Denominator: 63 leaf commands
|
||||
- Covered: 14
|
||||
- Coverage: 22.2%
|
||||
|
||||
## Summary
|
||||
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
|
||||
@@ -20,8 +20,6 @@
|
||||
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
|
||||
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
|
||||
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
|
||||
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
|
||||
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
|
||||
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
|
||||
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
|
||||
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |
|
||||
|
||||
@@ -5,7 +5,6 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -204,11 +203,6 @@ type wikiNodeInfo struct {
|
||||
ObjType string
|
||||
}
|
||||
|
||||
const (
|
||||
wikiDeleteVisibilityTimeout = 30 * time.Second
|
||||
wikiDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
|
||||
// original node token is gone. Wiki cleanup cannot use drive +delete because
|
||||
// wiki origin nodes need the backing obj_token and parent nodes must delete
|
||||
@@ -339,34 +333,28 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
|
||||
}
|
||||
|
||||
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
|
||||
var lastTransientErr error
|
||||
deadline := time.NewTimer(20 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
opts := clie2e.WaitOptions{
|
||||
Timeout: wikiDeleteVisibilityTimeout,
|
||||
Interval: wikiDeleteVisibilityPoll,
|
||||
TimeoutError: func() error {
|
||||
if lastTransientErr != nil {
|
||||
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
|
||||
}
|
||||
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
||||
},
|
||||
}
|
||||
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
for {
|
||||
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
|
||||
if err != nil {
|
||||
if isWikiVerifyTransientError(err) {
|
||||
lastTransientErr = err
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
@@ -387,31 +375,9 @@ func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
if isWikiNodeDeletedResult(result) {
|
||||
return true, nil
|
||||
}
|
||||
if isWikiVerifyTransientResult(result) {
|
||||
return false, wikiVerifyTransientError{
|
||||
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
type wikiVerifyTransientError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientError(err error) bool {
|
||||
var transient wikiVerifyTransientError
|
||||
return err != nil && errors.As(err, &transient)
|
||||
}
|
||||
|
||||
func wikiAPISuccess(stdout string) bool {
|
||||
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
|
||||
return ok.Bool()
|
||||
@@ -438,55 +404,6 @@ func isWikiNodeDeletedResult(result *clie2e.Result) bool {
|
||||
strings.Contains(combined, "not found")
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
payload := result.Stdout
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
payload = result.Stderr
|
||||
}
|
||||
if gjson.Get(payload, "error.type").String() != "internal" ||
|
||||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(gjson.Get(payload, "error.message").String())
|
||||
return strings.Contains(message, "http 429") ||
|
||||
strings.Contains(message, "http 500") ||
|
||||
strings.Contains(message, "http 502") ||
|
||||
strings.Contains(message, "http 503") ||
|
||||
strings.Contains(message, "http 504")
|
||||
}
|
||||
|
||||
func TestWikiVerifyTransientResult(t *testing.T) {
|
||||
t.Run("matches invalid response from transient http status", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
|
||||
}
|
||||
|
||||
require.True(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match unrelated invalid response", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match api errors", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
}
|
||||
|
||||
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user