Compare commits

..

3 Commits

Author SHA1 Message Date
fangshuyu
15263efe30 fix: normalize batch delete block IDs 2026-07-22 19:09:46 +08:00
fangshuyu
5b67085b32 fix: preserve docs title compatibility 2026-07-22 19:07:04 +08:00
fangshuyu
da149e66ba fix: validate unsafe docs write inputs 2026-07-22 18:52:59 +08:00
89 changed files with 535 additions and 4280 deletions

View File

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,36 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1638,7 +1608,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -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/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -55,7 +55,6 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
h.Set("x-tt-env", "ppe_agent_view")
return h
}

View File

@@ -51,7 +51,7 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
}
default:
return Endpoints{
Open: "https://open.feishu-pre.cn",
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
AppLink: "https://applink.feishu.cn",

View File

@@ -17,13 +17,6 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,18 +211,6 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -23,32 +22,6 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -56,7 +29,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %w", flagName, err)
return "", fmt.Errorf("%s: %v", flagName, err)
}
return value, nil
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,7 +4,6 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -72,72 +71,6 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

7
package-lock.json generated
View File

@@ -1,16 +1,15 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.11",
"cpu": [
"x64",
"arm64",
"riscv64"
"arm64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,13 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.74",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
"postinstall": "node scripts/install.js"
},
"os": [
"darwin",

View File

@@ -265,7 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -283,14 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,12 +52,11 @@ describe("getExpectedChecksum", () => {
);
});
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -107,7 +106,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("accepts a valid uppercase 64-character hex hash", () => {
it("matches case-insensitively", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -115,40 +114,6 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

View File

@@ -1,108 +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 STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

@@ -3,48 +3,49 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
VERSION=$(node -p "require('./package.json').version")
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
exit 1
fi
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
exit 1
fi
git fetch origin main
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"
echo "Successfully created and pushed tag ${TAG}"

View File

@@ -12,23 +12,10 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -54,17 +41,13 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,34 +82,6 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,6 +14,7 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -46,7 +47,21 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -61,9 +76,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
if err != nil {
return err
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,7 +12,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -59,17 +58,22 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -83,18 +87,6 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -157,142 +149,6 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -2435,14 +2435,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "high-risk-write",
Risk: "write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,7 +39,6 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -801,8 +801,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
},
},
{
@@ -851,8 +850,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -866,11 +865,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 records per call",
"Batch create supports max 200 rows per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -2056,8 +2055,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2357,7 +2356,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2426,7 +2424,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2475,7 +2472,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2487,33 +2483,6 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2550,7 +2519,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2585,7 +2553,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for select fields, whether multiple is false or true.",
"Use only for fields with options, such as select or multi-select fields.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,13 +19,12 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
},
Tips: append([]string{
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 records per call.",
"Batch create supports max 200 rows per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"io"
"io/fs"
"math"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
)
// ValidateLocalFileFlag validates that a local input path exists, is a regular
// file, and does not exceed maxBytes. Absolute and relative paths use
// the process filesystem namespace.
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return err
}
info, err := cmdutil.StatLocalFile(path)
if err != nil {
return localFileReadError(param, path, "inspect", err)
}
if err := localFileRegularError(param, path, info.Mode()); err != nil {
return err
}
if info.Size() > maxBytes {
return localFileSizeError(param, path, info.Size(), maxBytes)
}
return nil
}
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
// shortcuts. It accepts absolute and relative paths, enforces a hard size
// limit, and returns command-facing typed errors.
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return nil, err
}
f, err := cmdutil.OpenLocalFile(path)
if err != nil {
return nil, localFileReadError(param, path, "open", err)
}
defer func() {
if err := f.Close(); err != nil && retErr == nil {
data = nil
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
}
}()
openedInfo, err := f.Stat()
if err != nil {
return nil, localFileReadError(param, path, "inspect opened", err)
}
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
return nil, err
}
if openedInfo.Size() > maxBytes {
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
}
readLimit := maxBytes + 1
if maxBytes == math.MaxInt64 {
readLimit = maxBytes
}
data, err = io.ReadAll(io.LimitReader(f, readLimit))
if err != nil {
return nil, localFileReadError(param, path, "read", err)
}
if int64(len(data)) > maxBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
WithParam(param)
}
return data, nil
}
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
name, param, err := localFileFlagNames(flagName)
if err != nil {
return "", "", err
}
if ctx == nil || ctx.Cmd == nil {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
}
path = strings.TrimSpace(ctx.Str(name))
if path == "" {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
}
if _, err := validate.LocalInputPath(path); err != nil {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
WithParam(param).
WithCause(err)
}
if maxBytes < 0 {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
}
return path, param, nil
}
func localFileRegularError(param, path string, mode fs.FileMode) error {
if mode.IsRegular() {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is not a regular file", param, path).
WithParam(param)
}
func localFileReadError(param, path, op string, cause error) error {
if errors.Is(cause, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
WithParam(param).
WithCause(cause)
}
if errors.Is(cause, fs.ErrNotExist) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
WithParam(param).
WithCause(cause)
}
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
}
func localFileSizeError(param, path string, size, limit int64) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
WithParam(param)
}
func localFileFlagNames(flagName string) (name, param string, err error) {
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
if name == "" {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
}
return name, "--" + name, nil
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/spf13/cobra"
)
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
rctx := localFileTestRuntime(t, path)
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
}
got, err := rctx.ReadLocalFileFlag("file", 7)
if err != nil || string(got) != "content" {
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
}
}
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("file", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
}

View File

@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "XML str_replace rejects multiline pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_delete rejects empty ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
wantParam: "--block-id",
},
{
name: "block_delete rejects duplicate ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},

View File

@@ -17,6 +17,46 @@ import (
// ── V2 (OpenAPI) tests ──
func TestStripTopLevelXMLTitles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want string
}{
{
name: "single title",
content: "<title>Content title</title><p>body</p>",
want: "<p>body</p>",
},
{
name: "multiple titles",
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
want: "<p>body</p>",
},
{
name: "nested title is preserved",
content: "<callout><title>Nested</title></callout><p>body</p>",
want: "<callout><title>Nested</title></callout><p>body</p>",
},
{
name: "malformed XML is preserved",
content: "<title>Content title</title><p>A & B</p>",
want: "<title>Content title</title><p>A & B</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
}
})
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()

View File

@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"strings"
"github.com/larksuite/cli/errs"
@@ -16,7 +18,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
if runtime.Str("doc-format") == "xml" {
content = stripTopLevelXMLTitles(content)
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
type docContentRange struct {
start int64
end int64
}
// stripTopLevelXMLTitles preserves the established --title-wins contract while
// avoiding duplicate-title warnings from XML content. If the fragment is not
// well-formed XML, it is left untouched for the service to diagnose.
func stripTopLevelXMLTitles(content string) string {
const wrapperStart = "<root>"
wrapped := wrapperStart + content + "</root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
wrapperLen := int64(len(wrapperStart))
depth := 0
activeStart := int64(-1)
ranges := make([]docContentRange, 0, 1)
for {
tokenStart := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return content
}
switch value := token.(type) {
case xml.StartElement:
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
activeStart = tokenStart - wrapperLen
}
depth++
case xml.EndElement:
depth--
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
activeStart = -1
}
}
}
if len(ranges) == 0 {
return content
}
var result strings.Builder
cursor := int64(0)
for _, item := range ranges {
result.WriteString(content[int(cursor):int(item.start)])
cursor = item.end
}
result.WriteString(content[int(cursor):])
return strings.TrimSpace(result.String())
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))

View File

@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
if err := validateBlockDeleteIDs(blockID); err != nil {
return err
}
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func validateBlockDeleteIDs(raw string) error {
seen := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
blockID := strings.TrimSpace(part)
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
}
if _, ok := seen[blockID]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
}
seen[blockID] = struct{}{}
}
return nil
}
func normalizeBlockDeleteIDs(raw string) string {
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, ",")
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
if cmd == "block_delete" {
blockID = normalizeBlockDeleteIDs(blockID)
}
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {

View File

@@ -3,24 +3,11 @@
package slides
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var presentationFlagAliases = []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
all := []common.Shortcut{
return []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
@@ -31,39 +18,4 @@ func Shortcuts() []common.Shortcut {
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
for i := range all {
if hasPresentationFlag(all[i].Flags) {
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
}
}
return all
}
func hasPresentationFlag(flags []common.Flag) bool {
for _, flag := range flags {
if flag.Name == "presentation" {
return true
}
}
return false
}
// withPresentationFlagAliases accepts common agent-generated spellings for
// --presentation without registering extra flags. The aliases therefore stay
// out of help and completion while resolving to the canonical flag at parse
// time, matching the zero-round-trip compatibility used by Sheets.
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
for _, alias := range presentationFlagAliases {
if name == alias {
return pflag.NormalizedName("presentation")
}
}
return pflag.NormalizedName(name)
})
}
}

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWithPresentationFlagAliases(t *testing.T) {
for _, alias := range presentationFlagAliases {
t.Run(alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withPresentationFlagAliases(nil)(cmd)
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Fatalf("read --presentation: %v", err)
}
if got != "presABC" {
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
}
}
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
count := 0
for _, shortcut := range Shortcuts() {
if !hasPresentationFlag(shortcut.Flags) {
continue
}
count++
if shortcut.PostMount == nil {
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
continue
}
cmd := &cobra.Command{Use: shortcut.Command}
cmd.Flags().String("presentation", "", "presentation reference")
shortcut.PostMount(cmd)
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
continue
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
continue
}
if got != "presABC" {
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
}
}
if count == 0 {
t.Fatal("expected at least one slides shortcut with --presentation")
}
}

View File

@@ -37,7 +37,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -18,19 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
base := []string{"slides:presentation:screenshot"}
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -10,7 +10,6 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -101,40 +100,6 @@ func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
func parseTaskGUID(input string) (string, error) {
input = strings.TrimSpace(input)
invalid := func(format string, args ...interface{}) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
WithParam("--task-id").
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
}
if input == "" {
return "", invalid("task ID is empty")
}
lowerInput := strings.ToLower(input)
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
u, err := url.Parse(input)
if err != nil {
return "", invalid("invalid task applink: %v", err).WithCause(err)
}
guid := strings.TrimSpace(u.Query().Get("guid"))
if guid == "" {
return "", invalid("task applink is missing a non-empty guid query parameter")
}
return guid, nil
}
if taskDisplayNumberPattern.MatchString(input) {
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
}
return input, nil
}
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})

View File

@@ -4,11 +4,8 @@
package task
import (
"errors"
"net/url"
"testing"
"github.com/larksuite/cli/errs"
"github.com/smartystreets/goconvey/convey"
)
@@ -18,80 +15,3 @@ func TestShortcutsRegistration(t *testing.T) {
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
func TestParseTaskGUID(t *testing.T) {
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
{
name: "task applink",
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
want: "task-guid-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTaskGUID(tt.input)
if err != nil {
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
})
t.Run("rejects unusable task identifiers", func(t *testing.T) {
for _, input := range []string{
"",
"https://applink.larksuite.com/client/todo/detail",
"https://%",
"t12345",
} {
t.Run(input, func(t *testing.T) {
_, err := parseTaskGUID(input)
if err == nil {
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
if problem.Hint == "" {
t.Fatal("problem hint is empty")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--task-id" {
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
}
})
}
})
t.Run("preserves applink parse cause", func(t *testing.T) {
_, err := parseTaskGUID("https://%")
if err == nil {
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
}
var urlErr *url.Error
if !errors.As(err, &urlErr) {
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
}
})
}

View File

@@ -25,59 +25,45 @@ var CompleteTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUID(runtime.Str("task-id"))
return err
{Name: "task-id", Desc: "task id", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskID := url.PathEscape(taskGUID)
taskId := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
GET("/open-apis/task/v2/tasks/" + taskID).
GET("/open-apis/task/v2/tasks/" + taskId).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
PATCH("/open-apis/task/v2/tasks/" + taskID).
PATCH("/open-apis/task/v2/tasks/" + taskId).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return err
}
taskID := url.PathEscape(taskGUID)
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
// 2. If already completed, directly return success
if alreadyCompleted {
if completedAtStr != "" && completedAtStr != "0" {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}
@@ -87,19 +73,11 @@ var CompleteTask = common.Shortcut{
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
completedAt, _ := task["completed_at"].(string)
status := "todo"
if completedAt != "" && completedAt != "0" {
status = "done"
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
"status": status,
"completed_at": completedAt,
"already_completed": alreadyCompleted,
"guid": guid,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {

View File

@@ -4,12 +4,9 @@
package task
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -48,9 +45,6 @@ func TestCompleteTask(t *testing.T) {
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
`"status": "done"`,
`"completed_at": "1775174400000"`,
`"already_completed": false`,
},
},
}
@@ -115,98 +109,3 @@ func TestCompleteTask(t *testing.T) {
})
}
}
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
for _, method := range []string{"GET", "PATCH"} {
reg.Register(&httpmock.Stub{
Method: method,
URL: "/open-apis/task/v2/tasks/task-guid-applink",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-applink",
"summary": "Applink task",
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
"url": "https://example.com/task-guid-applink",
},
},
},
})
}
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete",
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
}
}
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-guid-done",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-done",
"summary": "Already done",
"completed_at": "1775174400000",
"url": "https://example.com/task-guid-done",
},
},
},
})
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
}
}
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("CompleteTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}

View File

@@ -24,14 +24,6 @@ func splitAndTrimCSV(input string) []string {
return out
}
func buildSearchPageParams(pageToken string) map[string]interface{} {
params := map[string]interface{}{}
if pageToken != "" {
params["page_token"] = pageToken
}
return params
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil

View File

@@ -37,31 +37,6 @@ func TestSplitAndTrimCSV(t *testing.T) {
}
}
func TestBuildSearchPageParams(t *testing.T) {
tests := []struct {
name string
pageToken string
wantToken string
wantKey bool
}{
{name: "first page omits token"},
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := buildSearchPageParams(tt.pageToken)
got, present := params["page_token"]
if present != tt.wantKey {
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
}
if tt.wantKey && got != tt.wantToken {
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string

View File

@@ -44,10 +44,8 @@ var SearchTask = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
@@ -76,9 +74,9 @@ var SearchTask = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
params := buildSearchPageParams(runtime.Str("page-token"))
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
@@ -92,7 +90,7 @@ var SearchTask = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
params["page_token"] = lastPageToken
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
@@ -185,6 +183,9 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSearchPaginationUsesQueryToken(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
command string
url string
}{
{
name: "tasks",
shortcut: SearchTask,
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
shortcut: SearchTasklist,
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
var pageTokens []string
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
shortcut := tt.shortcut
shortcut.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, shortcut, []string{
tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--page-limit", "2",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("search command failed: %v", err)
}
want := []string{"initial_pt", "next_pt"}
if !reflect.DeepEqual(pageTokens, want) {
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
}
})
}
}
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
t.Helper()
data, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal search dry-run preview: %v", err)
}
var envelope struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode search dry-run preview: %v", err)
}
if len(envelope.API) != 1 {
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
}
call := envelope.API[0]
if got, _ := call.Params["page_token"].(string); got != want {
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
}
if _, present := call.Body["page_token"]; present {
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
}
}
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
t.Helper()
return &httpmock.Stub{
Method: http.MethodPost,
URL: endpoint,
OnMatch: func(req *http.Request) {
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("read search request body: %v", err)
return
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("decode search request body: %v", err)
return
}
if _, present := payload["page_token"]; present {
t.Errorf("search request body unexpectedly contains page_token: %s", body)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": hasMore,
"page_token": responseToken,
"items": []interface{}{},
},
},
}
}

View File

@@ -37,12 +37,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" {
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
@@ -107,10 +104,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
@@ -118,8 +114,7 @@ func TestSearchTask_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantPageToken: "pt_demo",
wantParts: []string{`"query":"demo"`},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
@@ -148,11 +143,7 @@ func TestSearchTask_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
preview := SearchTask.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -41,10 +41,8 @@ var SearchTasklist = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
@@ -73,9 +71,9 @@ var SearchTasklist = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
params := buildSearchPageParams(runtime.Str("page-token"))
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
if err != nil {
return err
}
@@ -89,7 +87,7 @@ var SearchTasklist = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
params["page_token"] = lastPageToken
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
@@ -172,6 +170,9 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
@@ -80,10 +80,9 @@ func TestBuildTasklistSearchBody(t *testing.T) {
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
@@ -91,8 +90,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantPageToken: "pt_tl",
wantParts: []string{`"query":"Q2"`},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
@@ -118,11 +116,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
preview := SearchTasklist.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -27,42 +27,27 @@ var UpdateTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUIDs(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
preview := common.NewDryRunAPI()
for _, taskID := range taskIDs {
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return preview
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return err
}
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
@@ -70,11 +55,17 @@ var UpdateTask = common.Shortcut{
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
for _, taskID := range taskIDs {
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
if err != nil {
return err
}
@@ -85,28 +76,19 @@ var UpdateTask = common.Shortcut{
}
}
updateFields, _ := body["update_fields"].([]string)
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
confirmed := make(map[string]interface{})
for _, field := range updateFields {
if value, ok := task[field]; ok {
confirmed[field] = value
}
}
tasks = append(tasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
"confirmed": confirmed,
"guid": guid,
"url": urlVal,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"updated_fields": updateFields,
"tasks": tasks,
"tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
@@ -130,26 +112,6 @@ var UpdateTask = common.Shortcut{
},
}
func parseTaskGUIDs(input string) ([]string, error) {
parts := strings.Split(input, ",")
taskGUIDs := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
guid, err := parseTaskGUID(part)
if err != nil {
return nil, err
}
taskGUIDs = append(taskGUIDs, guid)
}
if len(taskGUIDs) == 0 {
_, err := parseTaskGUID("")
return nil, err
}
return taskGUIDs, nil
}
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string

View File

@@ -1,201 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestParseTaskGUIDs(t *testing.T) {
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
if err != nil {
t.Fatalf("parseTaskGUIDs() error = %v", err)
}
want := []string{"task-guid-1", "task-guid-2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
}
_, err = parseTaskGUIDs("task-guid-1,t12345")
if err == nil {
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
}
}
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
cmd.Flags().String("summary", "updated", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("data", "", "")
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
payload, err := json.Marshal(preview)
if err != nil {
t.Fatalf("marshal dry-run preview: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("decode dry-run preview: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
}
wantURLs := []string{
"/open-apis/task/v2/tasks/task-guid-1",
"/open-apis/task/v2/tasks/task-guid-2",
}
for i, call := range got.API {
if call.Method != "PATCH" {
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
}
if call.URL != wantURLs[i] {
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
}
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
t.Errorf("api[%d].params = %#v", i, call.Params)
}
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
}
}
}
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
first := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-1",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-1",
"url": "https://example.com/task-guid-1",
"summary": "server summary one",
"description": "server description one",
},
},
},
}
second := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-2",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-2",
"url": "https://example.com/task-guid-2",
"summary": "server summary two",
},
},
},
}
reg.Register(first)
reg.Register(second)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
"--summary", "requested summary",
"--description", "requested description",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("UpdateTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", envelope["data"])
}
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
t.Fatalf("updated_fields = %v, want [summary description]", got)
}
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 2 {
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
}
firstTask := tasks[0].(map[string]interface{})
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
t.Fatalf("first task identifiers = %#v", firstTask)
}
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary one", "description": "server description one",
}) {
t.Fatalf("first confirmed = %#v", got)
}
secondTask := tasks[1].(map[string]interface{})
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary two",
}) {
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
}
}
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,t12345",
"--summary", "must not be written",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("UpdateTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}
func stringSlice(value interface{}) []string {
items, _ := value.([]interface{})
result := make([]string, 0, len(items))
for _, item := range items {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}

View File

@@ -34,7 +34,6 @@ var VCMeetingJoin = common.Shortcut{
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
{Name: "password", Desc: "meeting password (if required)"},
{Name: "call-id", Desc: "correlation id forwarded from invite event"},
{Name: "view-url", Desc: "view URL forwarded to meeting participants"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mn := strings.TrimSpace(runtime.Str("meeting-number"))
@@ -96,8 +95,5 @@ func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{}
if cid := strings.TrimSpace(runtime.Str("call-id")); cid != "" {
body["call_id"] = cid
}
if viewURL := strings.TrimSpace(runtime.Str("view-url")); viewURL != "" {
body["view_url"] = viewURL
}
return body
}

View File

@@ -28,7 +28,7 @@
## 各命令
### +file-list
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
```bash
lark-cli apps +file-list --app-id app_xxx

View File

@@ -29,7 +29,7 @@ metadata:
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
@@ -104,18 +104,20 @@ metadata:
## 写入前置规则
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新、表单提交(`+form-submit`等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
## 表单与视图细节
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。

View File

@@ -8,7 +8,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`
- `+record-batch-create`使用 `create_records`,其每个元素都是 `Map<FieldNameOrID, CellValue>`
- `+record-batch-create``rows``CellValue[][]`,列顺序由 `fields` 决定
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`
- 一次 payload 里同一字段只用一种 key字段名或字段 ID不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
@@ -48,7 +48,7 @@ text 字段的 `style.type` 影响单元格检查逻辑:
### 2.3 select单选/多选)
`select` 字段用 `multiple` 区分单选和多选:`multiple=false` 时传选项名字符串,`multiple=true` 时传选项名数组。只支持写入字段中已有选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
单选用选项名字符串;多选用选项名数组。选项名建议与字段配置一致;写入未知选项时平台可能自动新增选项,因此不要把自然语言近义词当成已有选项传入
```json
{

View File

@@ -4,8 +4,6 @@
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
> **⚠️ 高风险写操作high-risk-write** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
@@ -23,11 +21,10 @@ lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交(高风险写操作,必须带 --yes
# 3⃣ 再提交
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--yes
--json '{"fields":{...}}'
```
`+form-detail` 的返回中要重点读取 `questions[].type``questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`
@@ -38,8 +35,7 @@ lark-cli base +form-submit \
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
--yes
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
@@ -51,17 +47,15 @@ lark-cli base +form-submit \
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}' \
--yes
}'
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot \
--yes
--as bot
# 预览 API 调用(不实际执行dry-run 无需 --yes
# 预览 API 调用(不实际执行)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
@@ -75,7 +69,6 @@ lark-cli base +form-submit \
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write不带 `--yes` 会返回 `confirmation_required` |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -145,8 +138,7 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}' \
--yes
--json '{"fields":{...}}'
```
## 输出格式
@@ -166,7 +158,6 @@ lark-cli base +form-submit \
## 提示
- **本命令为高风险写操作high-risk-write必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入

View File

@@ -7,13 +7,13 @@
## 适用场景(重点)
- 适合导入 CSV / Excel、外部系统一次性写入新数据。
- 先把每条输入数据映射为独立的字段对象,再组装 `create_records`
- 先把输入数据映射到合适的字段类型,再组装 `fields + rows`
## 推荐命令
```bash
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> \
--json '{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}'
--json '{"fields":["标题","状态"],"rows":[["任务 A","Open"],["任务 B","Done"]]}'
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> --json @batch-create.json
```
@@ -34,25 +34,23 @@ lark-cli base +record-batch-create --base-token <base_token> --table-id <table_i
本节只说明 `+record-batch-create` 的外层 JSON 形状CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
对象形态:
```json
{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}
```
对象形态:`{"fields":[...],"rows":[...]}`
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `create_records` | `Array<Map<FieldNameOrID, CellValue>>` | 是 | 记录字段对象数组;每条记录可以提交不同字段,单次最多 200 条 |
| `fields` | `string[]` | 是 | 字段 ID 或字段名数组 |
| `rows` | `CellValue[][]` | 是 | 二维数组,每一行按 `fields` 同序给 cell单次最多 200 行 |
## 返回重点
返回 `record_id_list` 和可选的 `ignored_fields`
返回 `fields``field_id_list``record_id_list``data`,其中 `data``fields` 列顺序对齐
## 坑点
- 每个 `create_records` 元素都是独立的记录字段对象,只提交该记录需要写入的字段
- 单次最多 200 条,超出需分批写入
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
- `fields` 与每行 `rows` 的列顺序必须一一对应
- 空单元格必须显式用 `null` 填充
- 单次最多 200 行,超出需分批写入
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先确认真实选项名。
## 参考

View File

@@ -55,7 +55,7 @@ lark-cli base +record-upsert --base-token <base_token> --table-id <table_id> --r
## 坑点
-`--record-id` 就一定更新;不传就一定创建,不会自动查重或按业务键 upsert。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` `+field-search-options` 确认目标选项存在
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先用 `+field-list` / `+field-search-options` 确认真实选项名
- 这是写入操作,执行前必须确认目标表和字段。
## 参考

View File

@@ -96,7 +96,6 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
每轮扩展都要保留非污染、可解释的 evidenceURL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter确需放宽检索范围时先向用户说明原因并征得确认。
## 参数

View File

@@ -29,7 +29,7 @@ Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声
"title": { "tag": "plain_text", "content": "卡片标题" },
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "lark-logo_colorful" },
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
]

View File

@@ -105,12 +105,12 @@
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "calendar_colorful" }
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
}
```
- `token` 必须`resource/icons.md` 的精确枚举中选择;禁止根据名称规律自行拼接 token。没有合适的 token 时省略 icon
- 场景速查:日历 `calendar_colorful`、待办 `todo_colorful`、投票 `vote_colorful`妙记 `file-lark-minutes_colorful`、多维表格 `wiki-bitable_colorful`、表单 `file-form_colorful`社区 `larkcommunity_colorful`、招聘 `hirelogo_colorful`、飞书品牌 `lark-logo_colorful`、Meego `meego_colorful`、AI `myai_colorful`、aPaaS `apaas_colorful`、审批 `approval_colorful`通用 AI `ai-common_colorful`
- `token``resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称
- 常用速查:通知 `notice_colorful`告警 `warning_colorful`、审批 `approve_colorful`日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`
### 1. 配色纪律(服务 P6 语义一致)
@@ -212,7 +212,7 @@ header 有三层能力,**尽量用满**(至少用 `title` + `icon``subtit
"title": { "tag": "plain_text", "content": "发版审批" },
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "approval_colorful" },
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
]

View File

@@ -34,19 +34,5 @@
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
## 彩色图标(精确 token
彩色图标必须从下表按**完整字符串**选择,禁止根据名称规律自行拼接。彩色 token 自带颜色,不要再推导其他后缀或变体。
| 含义 | token | 含义 | token |
|---|---|---|---|
| 日历 | `calendar_colorful` | 待办 | `todo_colorful` |
| 投票 | `vote_colorful` | 飞书妙记 | `file-lark-minutes_colorful` |
| 多维表格 | `wiki-bitable_colorful` | 表单 | `file-form_colorful` |
| 飞书社区 | `larkcommunity_colorful` | 招聘 | `hirelogo_colorful` |
| 飞书品牌 | `lark-logo_colorful` | Meego | `meego_colorful` |
| AI | `myai_colorful` | aPaaS | `apaas_colorful` |
| 审批 | `approval_colorful` | 通用 AI | `ai-common_colorful` |
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons

View File

@@ -101,9 +101,9 @@ metadata:
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口`summary.warning_count > 0` 时必须先做对应页面的截图复核**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
## 命令

View File

@@ -19,7 +19,7 @@
2.`slides +xml-get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。

View File

@@ -25,32 +25,19 @@ lark-cli slides +xml-get --as user \
--json
```
## Automated XML Layout Lint
## Automated XML Text Overlap Lint
`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
```bash
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
```
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
通过标准:
准出规则:
- `summary.error_count > 0``summary.release_ready == false`:阻断创建、替换或交付,必须先修复
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
- `slides[].status``blocked``needs_screenshot_review``passed`,可直接决定逐页后续动作。
- CLI 在存在 `error` 时退出码为 1只有 `warning` 时仍输出 JSON 并退出 0供截图复核链路继续执行。
每条 `error` / `warning` 都包含:
- `element_ids`:相关 XML 元素 ID
- `rule`:规则 ID、名称、阈值和比较关系
- `measurement`:越界量、交叠面积、覆盖率等实测值;
- `related_objects`:相关对象的类型与坐标框;
- `target``message``hint`:页码、语义说明和处理建议。
`sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收
常见 code 的处理方向:
@@ -64,10 +51,6 @@ python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <pres
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
| `icon_transparent_fill_color` | `<icon>``fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
## Screenshot QA

View File

@@ -188,13 +188,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<shadow>`
- `<content>`
`type` 常用取值:`text`(文本框)、`rect``round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle``diamond``parallelogram``trapezoid``custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron``flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。
其它可选属性:
- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
- `path`:仅 `type="custom"` 时使用SVG 路径串。
### line
```xml
@@ -205,16 +198,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### polyline
折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。
```xml
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
<border color="rgb(43, 47, 54)" width="2"/>
</polyline>
```
### img
```xml
@@ -255,7 +238,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<colgroup>` 直接子元素只有 `<col width="...">`width 定义列宽,默认 110。
- `<tr height="...">` 直接子元素只有 `<td>`height 定义行高,默认 37。
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>``<img>``<icon>`
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1`rowspan`(跨行,默认 1被合并覆盖的单元格不再写对应 `<td>`
表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Validate Slides XML structure and page layout through one release gate."""
from __future__ import annotations
@@ -43,25 +42,18 @@ ROUNDTRIP_SXSD_ATTRS = {
("chart", "updated"),
("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
class XmlLayoutLintError(Exception):
class XmlTextOverlapLintError(Exception):
pass
def fail(message: str) -> None:
raise XmlLayoutLintError(message)
raise XmlTextOverlapLintError(message)
def read_file(file_path: str | Path) -> str:
@@ -87,12 +79,8 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
def extract_attribute(tag_source: str, name: str) -> str | None:
match = re.search(
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
)
if not match:
return None
return match.group(1) if match.group(1) is not None else match.group(2)
match = re.search(fr'{re.escape(name)}="([^"]+)"', tag_source)
return match.group(1) if match else None
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
@@ -384,8 +372,6 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
tag_name = xml_local_name(element.tag)
current_path = f"{path}/{tag_name}" if path else tag_name
if tag_name in ROUNDTRIP_SXSD_TAGS:
return
if tag_name not in supported_tags:
issues.append(
{
@@ -646,9 +632,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
kind, attrs = match.group(1), match.group(2)
is_self_closing = attrs.rstrip().endswith("/")
content = ""
if kind in {"shape", "table"} and not is_self_closing:
if kind in {"shape", "table"}:
close_index = slide_xml.find(f"</{kind}>", match.end())
if close_index != -1:
content = slide_xml[match.end() : close_index]
@@ -1054,19 +1039,6 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return vertical_overlap >= min_vertical_overlap
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
source, target = sorted([left, right], key=lambda element: element["x"])
visual_width = estimate_text_max_line_width(source)
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
width = intersection_width(source_visual_bbox, target)
height = intersection_height(source_visual_bbox, target)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
if is_text_element(left) and not has_text_content(left):
return False
@@ -1194,6 +1166,9 @@ def detect_whiteboard_external_overlaps(
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
@@ -1219,7 +1194,12 @@ def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] == "text")
):
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1228,9 +1208,7 @@ def detect_elements_out_of_canvas(
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
}
overflow_details = [
f"{side} by {amount:g}px"
for side, amount in overflow.items()
if amount > CANVAS_OVERFLOW_TOLERANCE
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
]
if not overflow_details:
continue
@@ -1352,714 +1330,62 @@ def lint_slide(
"code": "bbox_overlap",
"elements": [left["id"], right["id"]],
"message": f'{left["id"]} overlaps {right["id"]}',
"hint": "Move or resize the elements so their visual bounds no longer intersect.",
**(
{"measurement": horizontal_text_overflow_measurement(left, right)}
if horizontal_overflow
else {}
),
}
)
return {
"slide_number": slide_number,
"element_count": len(elements),
"elements": elements,
"issues": issues,
}
return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
MIN_CONTAINER_WIDTH = 140
MIN_CONTAINER_HEIGHT = 160
MIN_SHORT_CARD_HEIGHT = 80
MIN_CONTAINER_AREA = 20_000
MIN_CONTENT_COVERAGE_RATIO = 0.15
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
MIN_SIMILAR_SHORT_CARD_COUNT = 2
LARGE_VISUAL_CHILD_RATIO = 0.35
LAYOUT_PANEL_SPAN_RATIO = 0.90
IMAGE_OVERLAY_MATCH_RATIO = 0.90
DENSITY_CONTAINMENT_TOLERANCE = 8
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
left = max(element["x"], container["x"])
top = max(element["y"], container["y"])
right = min(element["x"] + element["width"], container["x"] + container["width"])
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
if right <= left or bottom <= top:
return None
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
area = 0
for left, right in zip(x_coordinates, x_coordinates[1:]):
intervals = sorted(
(rect["y"], rect["y"] + rect["height"])
for rect in rectangles
if rect["x"] < right and rect["x"] + rect["width"] > left
)
covered_height = 0
interval_end: int | float | None = None
for top, bottom in intervals:
if interval_end is None:
covered_height += bottom - top
interval_end = bottom
elif bottom > interval_end:
covered_height += bottom - max(top, interval_end)
interval_end = bottom
area += (right - left) * covered_height
return area
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
return sum(
other is not element
and is_visually_rendered(other)
and other["kind"] == "shape"
and other["type"] == "rect"
and other["width"] >= MIN_CONTAINER_WIDTH
and other["height"] >= MIN_SHORT_CARD_HEIGHT
and element_area(other) >= MIN_CONTAINER_AREA
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
for other in elements
) >= MIN_SIMILAR_SHORT_CARD_COUNT
def is_layout_container(
element: dict[str, Any],
slide_width: int | float,
slide_height: int | float,
elements: list[dict[str, Any]] | None = None,
) -> bool:
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
elements is not None
and element["height"] >= MIN_SHORT_CARD_HEIGHT
and has_similar_short_card_peer(element, elements)
)
return (
element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and element["width"] >= MIN_CONTAINER_WIDTH
and has_supported_height
and element_area(element) >= MIN_CONTAINER_AREA
and not (
element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_width - 4
and element["height"] >= slide_height - 4
)
)
def is_edge_spanning_layout_panel(
element: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
)
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
container_area = element_area(container)
return any(
element["kind"] == "img"
and is_visually_rendered(element)
and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
for element in elements
)
def is_nested_in_layout_panel(
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> bool:
return any(
element is not container
and element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
for element in elements
)
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
elements = extract_elements(slide_xml)
elements_by_id = {element["id"]: element for element in elements}
root = ET.fromstring(slide_xml)
for node in root.iter():
if xml_local_name(node.tag) != "shape":
continue
element = elements_by_id.get(node.attrib.get("id", ""))
if element is None:
continue
content_node = next(
(child for child in node if xml_local_name(child.tag) == "content"),
None,
)
paragraphs = (
[
" ".join("".join(paragraph.itertext()).split())
for paragraph in content_node.iter()
if xml_local_name(paragraph.tag) == "p"
]
if content_node is not None
else []
)
raw_font_size = (
content_node.attrib.get("fontSize") if content_node is not None else None
) or node.attrib.get("fontSize")
try:
base_font_size = float(raw_font_size or 16)
except ValueError:
base_font_size = 16.0
element.update(
{
"textType": content_node.attrib.get("textType") if content_node is not None else None,
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
"fontSize": base_font_size,
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
}
)
if not has_text_content(element):
continue
declared_font_sizes = []
for descendant in node.iter():
raw_declared_font_size = descendant.attrib.get("fontSize")
if raw_declared_font_size is None:
continue
try:
declared_font_sizes.append(float(raw_declared_font_size))
except ValueError:
continue
if declared_font_sizes:
element["fontSize"] = max(declared_font_sizes)
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
icon_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
"kind": "icon",
"type": "icon",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": icon_alpha if icon_alpha is not None else 1,
"order": len(elements),
}
)
for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
polyline_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
"kind": "polyline",
"type": "polyline",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": polyline_alpha if polyline_alpha is not None else 1,
"order": len(elements),
}
)
for line_element in extract_line_elements(slide_xml):
line_element["order"] = len(elements)
elements.append(line_element)
return elements
def is_visually_rendered(element: dict[str, Any]) -> bool:
return element.get("alpha", 1) > 0
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, container) if estimated else None
return clipped_bbox(element, container)
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
if container["kind"] != "shape" or not has_text_content(container):
return None
text_proxy = {**container, "type": "text"}
estimated = estimate_text_visual_bbox(text_proxy)
return clipped_bbox(estimated, container) if estimated else None
def slide_content_visual_bbox(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "shape" and has_text_content(element):
estimated = own_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "line":
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
return clipped_bbox(element, slide_bbox)
return None
def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
def is_slide_content_present(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> bool:
# Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
# *anything* rendered here", not the richer "counts toward meaningful content density" bar
# that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
# decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
# count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
# instead of maintaining an allow-list that silently treats unlisted kinds as blank.
if not is_visually_rendered(element):
return False
if (
element["kind"] == "shape"
and element["type"] == "rect"
and not has_text_content(element)
and element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_bbox["width"] - 4
and element["height"] >= slide_bbox["height"] - 4
):
# A full-canvas plain rect is a background panel, not content -- same reasoning as
# is_layout_container's existing background exclusion. A slide with nothing else on it
# is still effectively blank.
return False
bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
return clipped_bbox(bbox, slide_bbox) is not None
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
return False
if not is_visually_rendered(element):
return False
return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
def detect_sparse_container_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for container in (
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
):
if (
is_edge_spanning_layout_panel(container, slide_width, slide_height)
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
or has_matching_image_overlay(container, elements)
):
continue
children = [
element
for element in elements
if element is not container
and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
]
if any(is_large_visual_child(child, container) for child in children):
continue
own_text_bbox = own_text_visual_bbox(container)
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
]
content_area = rectangle_union_area(rectangles) if rectangles else 0
coverage_ratio = content_area / element_area(container)
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
continue
issues.append(
{
"level": "warning",
"code": "sparse_container_content",
"target": {
"slide_number": slide_number,
"container_id": container["id"],
"container_type": container["type"],
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
},
"rule": {
"name": "large_container_visible_content_coverage",
"threshold": MIN_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"container_area": element_area(container),
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(children) + (1 if own_text_bbox else 0),
},
"elements": [container["id"], *[child["id"] for child in children]],
}
)
return issues
def detect_sparse_slide_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
content = [
(element, bbox)
for element in elements
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
]
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
return []
content_area = rectangle_union_area([bbox for _, bbox in content])
slide_area = slide_width * slide_height
coverage_ratio = content_area / slide_area
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
return []
return [
{
"level": "warning",
"code": "sparse_slide_content",
"target": {
"slide_number": slide_number,
"bbox": slide_bbox,
},
"rule": {
"name": "slide_visible_content_coverage",
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"slide_area": slide_area,
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(content),
},
"elements": [element["id"] for element, _ in content],
}
]
def detect_blank_slide(
elements: list[dict[str, Any]],
slide_number: int,
slide_width: int | float,
slide_height: int | float,
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
visible_elements = [
element for element in elements if is_slide_content_present(element, slide_bbox)
]
if visible_elements:
return []
return [
{
"level": "error",
"code": "blank_slide",
"schema_version": "2.0",
"target": {"slide_number": slide_number},
"rule": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count == 0",
},
"measurement": {
"visible_element_count": 0,
"declared_element_count": len(elements),
},
"elements": [element["id"] for element in elements],
"message": "slide has no visible content beyond empty layout shapes",
"hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
}
]
RULE_METADATA: dict[str, dict[str, Any]] = {
"xml_not_well_formed": {
"name": "xml_is_well_formed",
"comparison": "xml_parse_error == false",
},
"sml_prefixed_tag": {
"name": "sml_uses_default_namespace",
"comparison": "prefixed_sml_tag_count == 0",
},
"sxsd_unsupported_tag": {
"name": "tag_is_supported_by_slides_xml_schema",
"comparison": "unsupported_tag_count == 0",
},
"sxsd_unsupported_attr": {
"name": "attribute_is_supported_by_slides_xml_schema",
"comparison": "unsupported_attribute_count == 0",
},
"icon_missing_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_color_present == true",
},
"icon_transparent_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_alpha > 0",
},
"iconpark_unsupported_icon_type": {
"name": "iconpark_type_is_supported",
"comparison": "icon_type in iconpark_index",
},
"bbox_overlap": {
"name": "text_visual_bounds_do_not_overlap",
"comparison": "intersection_area == 0",
},
"text_may_overflow_shape": {
"name": "estimated_text_fits_declared_shape",
"comparison": "estimated_height <= available_height",
},
"whiteboard_external_overlap": {
"name": "whiteboard_does_not_cross_sibling_content",
"comparison": "external_overlap_count == 0",
},
"image_covers_text": {
"name": "image_does_not_cover_text",
"comparison": "intersection_area == 0",
},
"image_may_cover_vertical_text": {
"name": "image_vertical_text_occlusion_requires_review",
"comparison": "intersection_area == 0",
},
"table_resolved_size_mismatch": {
"name": "table_declared_size_matches_resolved_grid",
"comparison": "declared_size == resolved_size",
},
"blank_slide": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count > 0",
},
}
def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
if issue.get("rule"):
return {**issue["rule"], "id": issue["code"]}
if issue["code"].endswith("_out_of_canvas"):
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
root, xml_error = parse_xml_root(xml)
if xml_error:
return {
"id": issue["code"],
"name": "element_stays_within_slide_canvas",
"comparison": "max(left, top, right, bottom overflow) == 0",
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
"issues": [xml_error],
"slides": [],
}
return {
"id": issue["code"],
**RULE_METADATA.get(
issue["code"],
{"name": issue["code"], "comparison": "violation_count == 0"},
),
}
def issue_measurement(
issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
) -> dict[str, Any]:
if issue.get("measurement") is not None:
return issue["measurement"]
if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
left = elements_by_id.get(issue["elements"][0])
right = elements_by_id.get(issue["elements"][1])
if left and right:
left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
width = intersection_width(left_box, right_box)
height = intersection_height(left_box, right_box)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
if issue["code"].endswith("_out_of_canvas"):
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else []
iconpark_issues = validate_iconpark_icon_types(root) if root is not None else []
top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues]
if namespace_issues:
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
return {
"canvas": issue.get("canvas"),
"bbox": issue.get("bbox"),
"overflow": issue.get("overflow"),
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {
"slide_count": 0,
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
},
"issues": top_level_issues,
"slides": [],
}
measurement_keys = (
"line",
"column",
"tag",
"attr",
"iconType",
"line_count",
"line_height",
"estimated_height",
"available_height",
"overflow",
"dimension",
"declared_size",
"resolved_size",
"resolved_sizes",
"overlaps",
)
measured = {key: issue[key] for key in measurement_keys if key in issue}
return measured or {"violation_count": 1}
def related_object(element: dict[str, Any]) -> dict[str, Any]:
return {
"element_id": element["id"],
"kind": element["kind"],
"type": element["type"],
"bbox": {key: element[key] for key in ("x", "y", "width", "height")},
}
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
attrs = match.group(1)
start_x = extract_numeric_attribute(attrs, "startX")
start_y = extract_numeric_attribute(attrs, "startY")
end_x = extract_numeric_attribute(attrs, "endX")
end_y = extract_numeric_attribute(attrs, "endY")
if any(value is None for value in (start_x, start_y, end_x, end_y)):
continue
line_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
"kind": "line",
"type": "line",
"x": min(start_x, end_x),
"y": min(start_y, end_y),
"width": abs(end_x - start_x),
"height": abs(end_y - start_y),
"rotation": 0,
"alpha": line_alpha if line_alpha is not None else 1,
"order": len(elements),
}
)
return elements
def normalize_issue(
issue: dict[str, Any],
slide_number: int | None,
elements_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
normalized = dict(issue)
if normalized.get("level") == "info":
normalized["level"] = "warning"
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
normalized["schema_version"] = "2.0"
normalized["element_ids"] = element_ids
normalized["target"] = {
**({"slide_number": slide_number} if slide_number is not None else {}),
**normalized.get("target", {}),
}
normalized["rule"] = issue_rule(normalized)
normalized["measurement"] = issue_measurement(normalized, elements_by_id)
normalized["related_objects"] = [
related_object(elements_by_id[element_id])
for element_id in element_ids
if element_id in elements_by_id
presentation = parse_presentation(xml)
slides = [
lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
for index, slide_xml in enumerate(presentation["slides"])
]
if normalized["code"] == "sparse_container_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
container_id = normalized["target"].get("container_id", "unknown")
normalized.setdefault(
"message",
f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
)
elif normalized["code"] == "sparse_slide_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
normalized.setdefault(
"message",
f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot to decide whether the page is intentionally sparse.",
)
else:
normalized.setdefault("message", normalized["code"].replace("_", " "))
normalized.setdefault(
"hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
)
return normalized
def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
if errors:
return "blocked"
if warnings:
return "needs_screenshot_review"
return "passed"
def build_result(
source_path: str | None,
slide_size: dict[str, int | float],
top_level_issues: list[dict[str, Any]],
slides: list[dict[str, Any]],
) -> dict[str, Any]:
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
status = slide_status(all_errors, all_warnings)
result: dict[str, Any] = {
"schema_version": "2.0",
"tool": "xml_text_overlap_lint",
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
result = {
"file": source_path,
"slide_size": slide_size,
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
"summary": {
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"status": status,
"release_ready": error_count == 0,
"screenshot_review_required": warning_count > 0,
},
"document": {
"errors": document_errors,
"warnings": document_warnings,
"info_count": info_count,
},
"slides": slides,
}
@@ -2068,107 +1394,6 @@ def build_result(
return result
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
root, xml_error = parse_xml_root(xml)
if xml_error:
issue = normalize_issue(xml_error, None, {})
return build_result(
source_path,
{"width": 960, "height": 540},
[issue],
[],
)
if root is None:
raise AssertionError("parse_xml_root must return a root or error")
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root)
iconpark_issues = validate_iconpark_icon_types(root)
top_level_issues = [
normalize_issue(issue, None, {})
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
]
if any(issue["level"] == "error" for issue in top_level_issues):
return build_result(
source_path,
{"width": 960, "height": 540},
top_level_issues,
[],
)
presentation = parse_presentation(xml)
slides: list[dict[str, Any]] = []
for index, slide_xml in enumerate(presentation["slides"]):
slide_number = index + 1
geometry = lint_slide(
slide_xml,
slide_number,
presentation["width"],
presentation["height"],
)
density_elements = extract_density_elements(slide_xml)
extra_elements = [
element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
]
elements_by_id = {
element["id"]: element for element in [*density_elements, *extra_elements]
}
# geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
# decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
# with whatever actually triggered the issue, instead of density_elements' separate re-parse.
elements_by_id.update({element["id"]: element for element in geometry["elements"]})
extra_overflow_issues = detect_elements_out_of_canvas(
extra_elements,
presentation["width"],
presentation["height"],
)
raw_issues = [
*geometry["issues"],
*extra_overflow_issues,
*detect_blank_slide(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_container_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_slide_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
]
issues = [
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]
slides.append(
{
"slide_number": slide_number,
"status": slide_status(errors, warnings),
"element_count": len(elements_by_id),
"errors": errors,
"warnings": warnings,
"issues": issues,
}
)
return build_result(
source_path,
{"width": presentation["width"], "height": presentation["height"]},
top_level_issues,
slides,
)
def print_usage() -> None:
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
@@ -2191,6 +1416,6 @@ def run_cli(argv: list[str] | None = None) -> None:
if __name__ == "__main__":
try:
run_cli()
except XmlLayoutLintError as error:
except XmlTextOverlapLintError as error:
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@@ -12,8 +12,8 @@ from pathlib import Path
import xml_text_overlap_lint
class XmlTextOverlapLintGeometryTest(unittest.TestCase):
def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None:
class XmlTextOverlapLintTest(unittest.TestCase):
def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
issue_summaries = []
for slide in result.get("slides", []):
for issue in slide.get("issues", []):
@@ -28,6 +28,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
0,
f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
)
self.assertEqual(
result["summary"]["warning_count"],
0,
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
)
def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
script_path = Path(xml_text_overlap_lint.__file__).resolve()
@@ -44,7 +49,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(completed.stdout, "")
self.assertEqual(
completed.stderr,
f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n",
f"xml-text-overlap-lint error: unexpected argument: {input_path}need --input\n",
)
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
@@ -96,7 +101,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
sample_xml,
sample_name,
)
self.assertNoXmlTextOverlapLintErrors(result, sample_name)
self.assertNoXmlTextOverlapLintIssues(result, sample_name)
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -216,7 +221,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None:
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -230,11 +235,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
self.assertEqual(result["summary"]["slide_count"], 1)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["shape_out_of_canvas", "blank_slide"],
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -616,11 +618,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "bbox_overlap")
self.assertEqual(issue["elements"], ["source", "target"])
self.assertGreater(issue["measurement"]["intersection_area"], 0)
self.assertIsNotNone(issue.get("hint"))
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -753,7 +752,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"第一行\n第二行\n第三行",
)
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -771,9 +770,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
elements = xml_text_overlap_lint.extract_elements(
@@ -807,7 +805,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -819,10 +817,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -835,13 +832,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["img_out_of_canvas", "img_out_of_canvas"],
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
def test_lint_xml_allows_full_bleed_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -853,8 +846,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -871,36 +863,15 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content fontSize="18"><p>Visible content</p></content>
</shape>
<line id="connector" startX="80" startY="120" endX="980" endY="120"/>
</data>
</slide>
"""
)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "line_out_of_canvas")
self.assertEqual(issue["element_ids"], ["connector"])
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
self.assertEqual(issue["related_objects"][0]["kind"], "line")
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -922,27 +893,6 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1107,8 +1057,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "info")
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
@@ -1133,6 +1084,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
@@ -1201,7 +1153,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
}
script_path = Path(xml_text_overlap_lint.__file__).resolve()
with tempfile.TemporaryDirectory() as temp_dir:
for name, (table_xml, expected_warning_count) in cases.items():
for name, (table_xml, expected_info_count) in cases.items():
with self.subTest(case=name):
input_path = Path(temp_dir) / f"{name}.xml"
input_path.write_text(
@@ -1221,9 +1173,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
result = json.loads(completed.stdout)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], expected_info_count)
self.assertTrue(
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
result["slides"][0]["issues"],
)
@@ -1256,7 +1209,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
@@ -1268,710 +1221,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["level"], "info")
self.assertEqual(result["summary"]["error_count"], 0)
class XmlTextOverlapLintDensityTest(unittest.TestCase):
def test_lint_xml_blocks_blank_slide(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide id="content-slide">
<data>
<shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
<content fontSize="28"><p>Investment report</p></content>
</shape>
</data>
</slide>
<slide id="blank-slide">
<style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
<data/>
<note><content/></note>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["slide_count"], 2)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["status"], "blocked")
self.assertFalse(result["summary"]["release_ready"])
self.assertEqual(result["slides"][0]["issues"], [])
self.assertEqual(result["slides"][1]["element_count"], 0)
issue = result["slides"][1]["errors"][0]
self.assertEqual(issue["level"], "error")
self.assertEqual(issue["code"], "blank_slide")
self.assertEqual(issue["element_ids"], [])
self.assertEqual(issue["rule"]["id"], "blank_slide")
self.assertEqual(issue["measurement"]["visible_element_count"], 0)
self.assertEqual(issue["related_objects"], [])
def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img id="ghost" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "blank_slide")
def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
<shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
<content fontSize="15"><p>Core trends</p></content>
</shape>
<shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
<content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "sparse_container_content")
self.assertEqual(issue["target"]["container_id"], "trend-card")
self.assertEqual(issue["target"], {
"slide_number": 1,
"container_id": "trend-card",
"container_type": "rect",
"bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
})
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
self.assertEqual(issue["rule"], {
"name": "large_container_visible_content_coverage",
"threshold": 0.15,
"comparison": "content_coverage_ratio < threshold",
"id": "sparse_container_content",
})
self.assertEqual(issue["measurement"]["container_area"], 151700)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(
[obj["element_id"] for obj in issue["related_objects"]],
["trend-card", "trend-title", "trend-copy"],
)
self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review")
self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"])
def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
<shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
<content fontSize="14"><p>期待认识大家</p></content>
</shape>
<shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
<shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
<content fontSize="14"><p>化学一起讨论</p></content>
</shape>
<shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
<shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
<content fontSize="14"><p>吉他随时交流</p></content>
</shape>
<shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
<shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
<content fontSize="14"><p>共度美好四年</p></content>
</shape>
</data>
</slide>
"""
)
container_issues = [
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
]
self.assertEqual(
[issue["target"]["container_id"] for issue in container_issues],
["card-1", "card-2", "card-3", "card-4"],
)
self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["issues"]],
[
"sparse_container_content",
"sparse_container_content",
"sparse_container_content",
"sparse_container_content",
"sparse_slide_content",
],
)
def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
<content fontSize="14"><p>One short line</p></content>
</shape>
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
<content fontSize="14"><p>Another line</p></content>
</shape>
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
<content fontSize="14"><p>Third line</p></content>
</shape>
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
<content fontSize="14"><p>Fourth line</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
self.assertEqual(len(issues), 1)
issue = issues[0]
self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
self.assertEqual(issue["rule"]["threshold"], 0.035)
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
self.assertEqual(issue["measurement"]["content_element_count"], 4)
self.assertNotIn("background", issue["elements"])
def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
<shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
<content fontSize="14"><p>One concise summary</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="18">
<p>被吊物</p>
<p><span fontSize="36">32.0 t</span></p>
<p>钢结构模块</p>
</content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="18">
<p>被吊物</p>
<p>32.0 t</p>
<p>钢结构模块</p>
</content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
self.assertEqual(issue["measurement"]["content_element_count"], 1)
self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)
def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "sparse-card")
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
self.assertEqual(issue["measurement"]["content_element_count"], 1)
self.assertEqual(issue["elements"], ["sparse-card"])
def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
self_only = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
with_overlapping_child = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
<shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
self_issue = self_only["slides"][0]["issues"][0]
mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
self.assertEqual(
mixed_issue["measurement"]["visible_content_area"],
self_issue["measurement"]["visible_content_area"],
)
self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)
def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
elements = xml_text_overlap_lint.extract_density_elements(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(elements[0]["fontSize"], 36)
def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
elements = xml_text_overlap_lint.extract_density_elements(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
<shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
<content fontSize="18"><p>Following title</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(elements[0]["text"], "")
self.assertEqual(elements[1]["text"], "Following title")
def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "chart-card")
def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
<shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
<content fontSize="46"><p>Z</p></content>
</shape>
<shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(
[issue["target"]["container_id"] for issue in issues],
["letter-placeholder", "empty-placeholder"],
)
self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)
def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
<shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
<content fontSize="18"><p>梦境与现实</p></content>
</shape>
<shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
<content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["rule"]["threshold"], 0.15)
def test_lint_xml_allows_image_overlay_rect(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
<shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
<img id="ghost-overlay" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "card")
def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
<shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_counts_icons_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180" alpha="0">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0)
def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="70" height="70">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
self.assertEqual(issue["rule"]["threshold"], 0.15)
def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="100" height="100">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
<shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
<content fontSize="12.4"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<line id="l1" startX="100" startY="100" endX="800" endY="100"/>
<line id="l2" startX="100" startY="200" endX="800" endY="200"/>
<line id="l3" startX="100" startY="300" endX="800" endY="300"/>
<line id="l4" startX="100" startY="400" endX="800" endY="400"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="left" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content fontSize="14"><p>overlap text <span fontSize="96">big</span></p></content>
</shape>
<shape id="right" type="text" topLeftX="80" topLeftY="80" width="300" height="80">
<content fontSize="14"><p>other overlap text</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "bbox_overlap")
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
# from extract_elements), not the fontSize=96 max-descendant value that
# extract_density_elements computes for the same "left" element id.
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100}
card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100}
self.assertFalse(
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b])
)
self.assertTrue(
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c])
)
def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["schema_version"], "2.0")
def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="deco1" type="ellipse" topLeftX="60" topLeftY="60" width="300" height="300">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</shape>
<shape id="deco2" type="triangle" topLeftX="500" topLeftY="200" width="200" height="200">
<fill><fillColor color="rgba(220, 38, 38, 1)"/></fill>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None:
# A plain textless shape now counts as "not blank" (see the test above), but a
# full-bleed background rect must still NOT count toward sparse_slide_content's
# meaningful-content coverage ratio -- otherwise every slide with a background would
# trivially "pass" that density check.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
<content fontSize="14"><p>One short line</p></content>
</shape>
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
<content fontSize="14"><p>Another line</p></content>
</shape>
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
<content fontSize="14"><p>Third line</p></content>
</shape>
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
<content fontSize="14"><p>Fourth line</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertIn("sparse_slide_content", codes)
def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="visible" type="text" topLeftX = "80" topLeftY = "80" width = "300" height = "60">
<content><p>hello</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["element_count"], 1)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540">
<fill><fillColor color="rgba(240, 235, 220, 1)"/></fill>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertIn("blank_slide", codes)
def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None:
visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
ghost_1 = {
"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0,
}
ghost_2 = {
"kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0,
}
self.assertFalse(
xml_text_overlap_lint.has_similar_short_card_peer(
visible_card, [visible_card, ghost_1, ghost_2]
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -38,13 +38,6 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
> **从任务清单定位并修改任务的最短路径**
> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。
> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。
> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":"<tasklist_guid>"}' --as user`。
> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。
> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -9,23 +9,19 @@ Mark a task as completed.
```bash
# Complete a task
lark-cli task +complete --task-id "<task_guid>"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>"
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
| `--task-id <guid>` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
## Workflow
1. Confirm the task to complete.
2. Execute the command.
3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH.
4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification.
3. Report success.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -13,9 +13,6 @@ lark-cli task +update --task-id "<task_guid>" --summary "New Summary"
# Update multiple tasks' due dates
lark-cli task +update --task-id "<task_guid>,<another_task_guid>" --due "+2d"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>" --summary "New Summary"
# Update with JSON data
lark-cli task +update --task-id "<task_guid>" --data '{"description": "New description"}'
```
@@ -24,7 +21,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
| `--task-id <guid>` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--summary <text>` | No | New summary/title for the task. |
| `--description <text>` | No | New description for the task. |
| `--due <time>` | No | New due date (supports relative time). |
@@ -34,8 +31,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
1. Confirm with the user the tasks to update and the fields.
2. Execute `lark-cli task +update --task-id "..." ...`
3. Read `data.updated_fields` and `data.tasks[].confirmed` from the result and report only the fields confirmed by the server.
4. Do not routinely call `task tasks get` after the update when `confirmed` already contains the required state. Query details only if a required field is absent or the user explicitly asks for a full verification.
3. Report the successful updates.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -19,9 +19,6 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --password 8888
# 从邀请事件透传 call_id参见「如何获取输入参数」
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --call-id a08e06bf-9a41-44e4-a89c-a7871899e783
# 携带 view URL 入会(透传给会中参会人)
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --view-url https://example.com/view
# 输出格式
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
@@ -36,7 +33,6 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
| `--call-id <id>` | 否 | 从 `vc.bot.meeting_invited_v1` 邀请事件透传的 `call_id`原样回传即可。Agent 主动入会或无邀请事件来源时不传 |
| `--view-url <url>` | 否 | 入会时携带的 view URL会透传给会中其他参会人不需要向参会人展示视图时不传 |
| `--dry-run` | 否 | 预览 API 调用,不实际加入会议;会议号或身份不确定时先用它确认请求 |
## 核心约束
@@ -84,7 +80,6 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
| `meeting-number` | 会议号由主持人分享;也可从会议链接尾部解析 9 位数字 |
| `password` | 若会议设置了入会密码,由主持人提供 |
| `call-id` | 由 `vc.bot.meeting_invited_v1` 邀请事件的 `call_id` 字段携带Agent 收到事件时透传过来;无邀请事件场景(如 Agent 主动入会)不传 |
| `view-url` | 由调用方(操控 bot 的应用 / agent按当前会议场景生成指向要展示给参会人的视图无展示视图需求时不传 |
## Agent 组合场景

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath(t *testing.T) {
setAppsDryRunEnv(t)
absolutePath := filepath.Join(t.TempDir(), "report.pdf")
require.NoError(t, os.WriteFile(absolutePath, []byte("dry-run-input"), 0o600))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", absolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "report.pdf", clie2e.DryRunGet(result.Stdout, "api.0.body.file_name").String())
}
func TestAppsFileUploadDryRun_RejectsMissingHostPath(t *testing.T) {
setAppsDryRunEnv(t)
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "report.pdf")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", missingAbsolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--file", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadLiveWorkflow(t *testing.T) {
if strings.TrimSpace(os.Getenv("LARKSUITE_CLI_CONFIG_DIR")) == "" {
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated live-test config")
}
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_FILE_APP_ID"))
if appID == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_FILE_APP_ID to a dedicated app for upload/delete testing")
}
fileName := fmt.Sprintf("lark-cli-host-path-e2e-%d.txt", time.Now().UnixNano())
absolutePath := filepath.Join(t.TempDir(), fileName)
content := []byte("host-path-live-e2e")
require.NoError(t, os.WriteFile(absolutePath, content, 0o600))
remotePath := ""
t.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
if remotePath == "" {
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-list", "--app-id", appID, "--name", fileName},
DefaultAs: "user",
})
if listErr != nil || listResult.ExitCode != 0 {
clie2e.ReportCleanupFailure(t, "find uploaded file "+fileName, listResult, listErr)
return
}
for _, item := range gjson.Get(listResult.Stdout, "data.items").Array() {
if item.Get("file_name").String() == fileName {
remotePath = item.Get("path").String()
break
}
}
}
if remotePath == "" {
return
}
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-delete", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "delete uploaded file "+remotePath, deleteResult, deleteErr)
if deleteErr == nil && deleteResult != nil && gjson.Get(deleteResult.Stdout, "data.results.0.status").String() != "ok" {
t.Errorf("cleanup delete did not report success: %s", deleteResult.Stdout)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+file-upload", "--app-id", appID, "--file", absolutePath},
DefaultAs: "user",
})
require.NoError(t, err)
uploadResult.AssertExitCode(t, 0)
uploadResult.AssertStdoutStatus(t, true)
remotePath = gjson.Get(uploadResult.Stdout, "data.path").String()
require.NotEmpty(t, remotePath, "stdout:\n%s", uploadResult.Stdout)
assert.Equal(t, fileName, gjson.Get(uploadResult.Stdout, "data.file_name").String(), "stdout:\n%s", uploadResult.Stdout)
getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+file-get", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || gjson.Get(result.Stdout, "data.path").String() != remotePath
},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, int64(len(content)), gjson.Get(getResult.Stdout, "data.size_bytes").Int(), "stdout:\n%s", getResult.Stdout)
}

View File

@@ -1,11 +1,11 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (19/19)
- API dry-run coverage: 100% (17/17 API-backed commands)
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (18/18)
- API dry-run coverage: 100% (16/16 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup.
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
@@ -14,8 +14,6 @@
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
- `TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath`: dry-run validates an absolute local file and derives its basename without uploading it; a missing source path is rejected before preview.
- `TestAppsFileUploadLiveWorkflow`: fixture-gated absolute-path upload, `+file-get` readback, and `+file-delete` cleanup in a dedicated app.
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
@@ -25,7 +23,7 @@
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
## Command Table
@@ -37,7 +35,6 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
| ✓ | apps +file-upload | shortcut | apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath; apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_RejectsMissingHostPath; apps_file_upload_live_test.go::TestAppsFileUploadLiveWorkflow | `--app-id`, `--file` (absolute or relative local path) | live workflow uses `LARK_CLI_E2E_APPS_FILE_APP_ID`, reads metadata back, and deletes the uploaded file |
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormSubmitDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-submit",
"--share-token", "shrXXXX",
"--json", `{"fields":{"Rating":5}}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/submit")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}

View File

@@ -15,7 +15,6 @@ import (
)
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
@@ -36,7 +35,7 @@ func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
"base", "+record-batch-create",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"create_records":[{"Name":"alpha","Status":"Open","Score":10},{"Name":"beta","Status":"Open","Score":15}]}`,
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
},
DefaultAs: "bot",
})

View File

@@ -56,7 +56,7 @@
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
| ✓ | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update |

View File

@@ -44,7 +44,6 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
"--doc", docToken,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA,blkB,blkC",
"--block-id", "blkA, blkB, blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Flag title",
"--content", "<title>Content title</title><p>body</p>",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
}
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
want string
}{
{
name: "multiline XML str_replace",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
want: "must be inline",
},
{
name: "duplicate block delete ID",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
want: "duplicate ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
})
}
}

View File

@@ -366,7 +366,6 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
@@ -412,7 +411,6 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--key-result-id", krID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
@@ -428,7 +426,6 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--objective-id", obj.ObjectiveID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
@@ -450,7 +447,6 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
"--cycle-id", cycleID,
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live objective")
result.AssertExitCode(t, 0)
@@ -470,7 +466,6 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
"--objective-id", objectiveID,
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live key result")
result.AssertExitCode(t, 0)
@@ -504,7 +499,6 @@ func TestOKR_BatchCreateLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -550,7 +544,6 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -588,7 +581,6 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -648,7 +640,6 @@ func TestOKR_ReorderLive(t *testing.T) {
"--level", "objective",
"--ops", string(opsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -659,7 +650,6 @@ func TestOKR_ReorderLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -712,7 +702,6 @@ func TestOKR_WeightLive(t *testing.T) {
"--level", "objective",
"--weights", string(weightsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -723,7 +712,6 @@ func TestOKR_WeightLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesPresentationAliasesDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
aliases := []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
for _, alias := range aliases {
t.Run(alias, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+xml-get",
"--" + alias, "presAliasDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presAliasDryRun",
gjson.Get(result.Stdout, "data.api.0.url").String(),
result.Stdout,
)
})
}
}

View File

@@ -1,99 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_id_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_id_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
run := func(t *testing.T, args []string) *clie2e.Result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
return result
}
t.Run("GUID and applink produce equivalent update requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+update", "--task-id", "task-guid-123", "--summary", "updated", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+update", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-123", "--summary", "updated", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-123"
require.Equal(t, wantURL, clie2e.DryRunGet(guidResult.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(applinkResult.Stdout, "api.0.url").String())
require.Equal(t, clie2e.DryRunGet(guidResult.Stdout, "api.0.body").Raw, clie2e.DryRunGet(applinkResult.Stdout, "api.0.body").Raw)
})
t.Run("multi-ID update previews every mutation", func(t *testing.T) {
result := run(t, []string{
"task", "+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/task?guid=task-guid-2",
"--summary", "updated",
"--dry-run",
})
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-1", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-2", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Raw, clie2e.DryRunGet(result.Stdout, "api.1.params").Raw)
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Raw, clie2e.DryRunGet(result.Stdout, "api.1.body").Raw)
})
t.Run("GUID and applink produce equivalent completion requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+complete", "--task-id", "task-guid-456", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+complete", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-456", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-456"
for _, result := range []*clie2e.Result{guidResult, applinkResult} {
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.1.url").String())
}
})
for _, shortcut := range []string{"+update", "+complete"} {
t.Run(shortcut+" rejects display numbers", func(t *testing.T) {
args := []string{"task", shortcut, "--task-id", "t12345", "--dry-run"}
if shortcut == "+update" {
args = append(args, "--summary", "must not be written")
}
result := run(t, args)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "guid=", "stderr:\n%s", result.Stderr)
require.False(t, gjson.Get(result.Stdout, "data.api").Exists(), "invalid input must not emit a dry-run API request\nstdout:\n%s", result.Stdout)
})
}
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/url"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalSummary := "lark-cli-e2e-task-id-original-" + suffix
updatedSummary := "lark-cli-e2e-task-id-updated-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": originalSummary,
"description": "created by task ID handling workflow",
},
})
taskApplink := "https://applink.larksuite.com/client/todo/task?guid=" + url.QueryEscape(taskGUID)
t.Run("update accepts task applink", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", taskApplink, "--summary", updatedSummary},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.tasks.0.confirmed.summary").String(), "stdout:\n%s", result.Stdout)
})
t.Run("display number is rejected without modifying task", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", "t12345", "--summary", "must-not-be-written-" + suffix},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
getResult, getErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, getErr)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, updatedSummary, gjson.Get(getResult.Stdout, "data.task.summary").String(), "stdout:\n%s", getResult.Stdout)
})
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestTask_SearchPaginationDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_search_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_search_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
command string
url string
}{
{
name: "tasks",
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, tt.url, clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "initial_pt", clie2e.DryRunGet(out, "api.0.params.page_token").String(), out)
require.Equal(t, "pagination", clie2e.DryRunGet(out, "api.0.body.query").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.page_token").Exists(), out)
})
}
}