Compare commits

...

7 Commits

Author SHA1 Message Date
niezhiwei
7f9c6d9bfb fix(slides): reindent xml-get output with a stdlib-only formatter
Reland of #1987 (reverted in #2013 over the BSD attribution gap of
github.com/beevik/etree) with the formatter rebuilt on the Go standard
library only — no third-party dependency.

encoding/xml serves purely as a tokenizer; the output is assembled
exclusively from verbatim byte slices of the server content plus
indentation inserted between structural elements. Nothing is
parsed-and-reserialized, so CDATA sections, whitespace character
references in any spelling, entity lexical forms, attribute quoting,
and in-tag whitespace survive byte-for-byte — the character-reference
masking machinery of the etree implementation is no longer needed.

Behavior is unchanged from the reverted PR: --raw stdout and --output
files are reindented (never inside the schema's mixed-content
text-bearing elements), the default JSON envelope carries the server's
XML verbatim without parsing, and formatting failures fall back to the
original content with a stderr warning and pretty_printed: false in
--output file metadata. All contract tests carry over unweakened; a
differential probe against the etree implementation over 53 inputs was
byte-identical except six cases where the new formatter preserves the
original bytes more faithfully (each pinned in tests).
2026-07-23 02:14:06 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's  /	/
/
 whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
25 changed files with 1522 additions and 119 deletions

View File

@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
goreleaser:
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
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ 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: ${{ secrets.GITHUB_TOKEN }}
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
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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
run: |
set -euo pipefail
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; }
(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"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,6 +2,36 @@
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
@@ -1608,6 +1638,7 @@ 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/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/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
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

7
package-lock.json generated
View File

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

View File

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

View File

@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
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"
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ 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

@@ -0,0 +1,108 @@
#!/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

@@ -0,0 +1,66 @@
// 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,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# 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
VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# 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
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
exit 1
fi
# 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
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
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
git fetch origin main
echo "Successfully created and pushed tag ${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}"

View File

@@ -2435,16 +2435,14 @@ 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", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
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 {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -801,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
@@ -850,8 +851,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 -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"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"}]`,
@@ -865,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"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}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"Batch create supports max 200 records 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"}]`,

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 fields with options, such as select or multi-select fields.",
"Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(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},
{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},
},
Tips: append([]string{
"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.",
"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}]}.`,
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Batch create supports max 200 records 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 -> "Todo"; multi-select -> ["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 (multiple=false) -> "Todo"; select (multiple=true) -> ["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

@@ -16,9 +16,10 @@ import (
)
// SlidesXMLGet fetches the full XML presentation content. When --output is
// provided it writes to a local file; otherwise it returns the XML in the
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
// and use --raw for direct XML stdout.
// provided it writes reindented XML to a local file, and --raw prints
// reindented XML to stdout; otherwise it returns the server's original
// content unmodified in the standard JSON envelope. Use --slide-id or
// --slide-number to fetch one page.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
@@ -30,8 +31,8 @@ var SlidesXMLGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
@@ -108,10 +109,10 @@ var SlidesXMLGet = common.Shortcut{
}
dry.GET(path).Params(params)
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
}
if runtime.Bool("raw") {
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
}
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
},
@@ -250,22 +251,31 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
return content, out, nil
}
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
// Only the text surfaces are reindented: --raw stdout and --output files are
// read directly by humans and line tools. The JSON envelope carries the
// server content verbatim instead -- inside a JSON string every newline is
// escaped to \n, so formatting there buys no readability and only inflates
// the payload, while passthrough keeps that read path byte-exact without
// even parsing the content.
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
if outputPath == "" {
if !runtime.Bool("raw") {
runtime.OutFormatRaw(out, nil, nil)
return nil
}
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
}
return nil
}
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
ContentLength: int64(len(formatted)),
}, bytes.NewReader([]byte(formatted)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
@@ -280,6 +290,7 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
"pretty_printed": prettyPrinted,
}
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
if value, ok := out[key]; ok {
@@ -289,3 +300,17 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
runtime.Out(fileOut, nil)
return nil
}
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
// content that is not strictly valid XML, callers still receive the original
// content and a warning on stderr instead of losing the read path. The bool
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
// --output file metadata.
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
out, err := prettyPrintXML(xmlContent)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
return xmlContent, false
}
return out, true
}

View File

@@ -23,6 +23,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// Golden value computed independently of prettyPrintXML (not derived by
// calling it): a bug in prettyPrintXML itself must not be able to make
// this assertion pass by construction.
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -60,10 +64,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
if string(got) != wantXML {
t.Fatalf("saved XML = %q, want %q", got, wantXML)
}
if strings.Contains(stdout.String(), xml) {
if strings.Contains(stdout.String(), wantXML) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
@@ -80,8 +84,11 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
if data["pretty_printed"] != true {
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
}
if data["size"] != float64(len(wantXML)) {
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
@@ -96,7 +103,12 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// The JSON envelope carries the server content verbatim: no reindentation
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
// would fail this by inserting indentation; the &#32; reference
// additionally guards against a naive parse-and-reserialize round trip,
// which would decode it to a literal space.
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span>&#32;<strong>World</strong></p></content></shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -122,11 +134,14 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
data := decodeShortcutData(t, stdout)
presentation := data["xml_presentation"].(map[string]interface{})
if got := presentation["content"]; got != xml {
t.Fatalf("content = %q, want %q", got, xml)
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
}
if got := data["xml_presentation_id"]; got != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if strings.Contains(stdout.String(), "content_saved") {
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
}
@@ -136,6 +151,8 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// --jq extracts fields from the envelope, and the envelope carries the
// server content verbatim, so the filter yields the single-line original.
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -161,15 +178,18 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != xml {
t.Fatalf("stdout = %q, want XML content %q", got, xml)
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
}
}
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// Golden value computed independently of prettyPrintXML; see the comment
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -193,16 +213,32 @@ func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != xml {
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
if got := stdout.String(); got != wantXML {
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
}
}
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
for _, flag := range SlidesXMLGet.Flags {
if flag.Name != "raw" {
continue
}
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
}
return
}
t.Fatal("--raw flag not found")
}
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
// Golden value computed independently of prettyPrintXML; see the comment
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -244,8 +280,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
if err != nil {
t.Fatalf("read saved slide XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
if string(got) != wantXML {
t.Fatalf("saved XML = %q, want %q", got, wantXML)
}
data := decodeShortcutData(t, stdout)
if data["scope"] != "slide" {
@@ -263,6 +299,8 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// The slide envelope carries the server content verbatim, like the
// presentation envelope.
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -305,11 +343,14 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
}
slide := data["slide"].(map[string]interface{})
if slide["content"] != xml {
t.Fatalf("content = %q, want %q", slide["content"], xml)
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
}
if slide["slide_id"] != "slide_2" {
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
@@ -515,3 +556,341 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
}
}
func TestPrettyPrintXML(t *testing.T) {
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if !strings.Contains(got, "\n") {
t.Fatalf("expected reindented output with newlines, got %q", got)
}
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
}
if !strings.Contains(got, "<data/>") {
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
}
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
}
}
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
t.Fatal("expected an error for malformed XML, got nil")
}
}
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
// element docs) and CR/LF references whose lexical form is needed to avoid
// XML line-ending normalization on a later parse. An XML parser decodes the
// references into literal whitespace. The formatter must preserve their
// lexical representation for safe read-modify-write workflows.
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"space in p", `<content><p>&#32;</p></content>`, "<content>\n <p>&#32;</p>\n</content>\n"},
{"tab in p", `<content><p>&#9;</p></content>`, "<content>\n <p>&#9;</p>\n</content>\n"},
{"space in nested span", `<content><p><span>&#32;</span></p></content>`, "<content>\n <p><span>&#32;</span></p>\n</content>\n"},
{"hex space", `<content><p>&#x20;</p></content>`, "<content>\n <p>&#x20;</p>\n</content>\n"},
{"zero-padded tab", `<content><p>&#0009;</p></content>`, "<content>\n <p>&#0009;</p>\n</content>\n"},
{"carriage return", `<content><p>A&#13;B</p></content>`, "<content>\n <p>A&#13;B</p>\n</content>\n"},
{"line feed", `<content><p>A&#10;B</p></content>`, "<content>\n <p>A&#10;B</p>\n</content>\n"},
{"hex carriage return", `<content><p>A&#xD;B</p></content>`, "<content>\n <p>A&#xD;B</p>\n</content>\n"},
{"hex line feed", `<content><p>A&#xA;B</p></content>`, "<content>\n <p>A&#xA;B</p>\n</content>\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "title literal space",
input: `<presentation><title> </title><slide/></presentation>`,
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
},
{
name: "title escaped space",
input: `<presentation><title>&#32;</title><slide/></presentation>`,
want: "<presentation>\n <title>&#32;</title>\n <slide/>\n</presentation>\n",
},
{
name: "title whitespace CDATA",
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
},
{
name: "chart field literal space",
input: `<chartData><chartField name="x"> </chartField></chartData>`,
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
},
{
name: "title adjacent text and CDATA",
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
// critical case: &#32; sitting as a bare sibling text node directly between
// two inline elements, not wrapped in its own tag -- the literal reading of
// the schema's "标签之间...请使用&#32;" guidance, e.g. a plain-styled space
// between two differently formatted words at a pptx run boundary. A fix
// that only special-cases "element whose sole content is whitespace" does
// not cover this: the whitespace here is one of several children of <p>,
// not the sole child of <span>.
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
input := `<content><p><span>Hello</span>&#32;<strong>World</strong></p></content>`
want := "<content>\n <p><span>Hello</span>&#32;<strong>World</strong></p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
// feature's actual point: a shape with many paragraphs becomes navigable
// (each <p> on its own indented line), while every paragraph's own rich
// text -- including an inline formatting boundary -- stays byte-for-byte
// unchanged.
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLIdempotent(t *testing.T) {
input := `<presentation><slide id="s1"><shape id="a"><content><p>A&#32;&#32;B&#9;C&#13;D&#10;E</p></content><style/></shape></slide></presentation>`
once, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML (first pass): %v", err)
}
twice, err := prettyPrintXML(once)
if err != nil {
t.Fatalf("prettyPrintXML (second pass): %v", err)
}
if once != twice {
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
}
}
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
content := "<presentation><title>\x0b</title><slide/></presentation>"
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--raw",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != content {
t.Fatalf("stdout = %q, want original content %q", got, content)
}
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
}
}
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
// envelope contract: the content is never parsed, so even malformed XML
// flows through byte for byte with no fallback warning and no
// pretty_printed field.
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
content := `<slide><data></slide>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide": map[string]interface{}{
"slide_id": "slide_1",
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
slide, _ := data["slide"].(map[string]interface{})
if slide == nil {
t.Fatalf("missing slide: %#v", data)
}
if got, _ := slide["content"].(string); got != content {
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
}
}
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
// the slide-scope passthrough test for the presentation-scope fetch branch,
// which is a separate code path.
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
content := `<presentation><slide></presentation>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
presentation, _ := data["xml_presentation"].(map[string]interface{})
if presentation == nil {
t.Fatalf("missing xml_presentation: %#v", data)
}
if got, _ := presentation["content"].(string); got != content {
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
}
}
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
content := `<presentation><slide></presentation>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "fallback.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
if err != nil {
t.Fatalf("read fallback XML: %v", err)
}
if string(got) != content {
t.Fatalf("saved XML = %q, want original content %q", got, content)
}
data := decodeShortcutData(t, stdout)
if data["pretty_printed"] != false {
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
}
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
}
}

View File

@@ -0,0 +1,260 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/xml"
"errors"
"io"
"slices"
"strings"
)
// textBearingTags are the SML elements whose schema content model is
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
// container and its inline formatting children, plus chart title/subtitle.
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
// or tab is represented via &#32;/&#9; character references. Reindentation
// never descends into these elements; their entire subtree is copied
// verbatim from the input, so those references keep their exact spelling.
var textBearingTags = map[string]bool{
"p": true,
"strong": true,
"em": true,
"u": true,
"span": true,
"del": true,
"a": true,
"shadow": true,
"outline": true,
"chartTitle": true,
"chartSubTitle": true,
}
// tokenKind classifies a raw XML token for reindentation purposes.
type tokenKind uint8
const (
tokenStartElement tokenKind = iota // <name ...> or <name .../>
tokenEndElement // </name>, or zero-width after <name .../>
tokenCharData // text, character/entity references, or one CDATA section
tokenOther // comment, processing instruction, or directive
)
// rawToken records where one XML token lives inside the original input:
// input[start:end] is the token's exact source bytes. The decoded token
// value is deliberately discarded (only the element's local name is kept),
// which is the core invariant of this formatter: output can only ever be
// assembled from verbatim slices of the input, never from re-encoded data.
type rawToken struct {
kind tokenKind
start int // byte offset of the token's first source byte
end int // byte offset one past the token's last source byte
local string // local element name (namespace prefix stripped); start elements only
match int // start element: index of its matching end token; -1 otherwise
}
// tokenize runs encoding/xml over the whole input purely as a tokenizer and
// returns every token annotated with its raw byte range. Ranges come from
// Decoder.InputOffset, which counts bytes (multi-byte UTF-8 content cannot
// skew them), and consecutive tokens tile the input exactly, so slicing
// between them loses nothing.
//
// The full document is decoded before anything is emitted: any syntax error
// (mismatched or unclosed tags, invalid characters such as \x0b, undefined
// entities, bare ]]> in text, ...) fails the whole pretty-print, keeping the
// strict-parse behavior the fallback path in prettyPrintXMLOrOriginal
// depends on.
func tokenize(input string) ([]rawToken, error) {
decoder := xml.NewDecoder(strings.NewReader(input))
var tokens []rawToken
var openElements []int // indices into tokens of currently open start elements
pos := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
end := int(decoder.InputOffset())
raw := rawToken{start: pos, end: end, match: -1}
switch t := token.(type) {
case xml.StartElement:
raw.kind = tokenStartElement
raw.local = t.Name.Local
openElements = append(openElements, len(tokens))
case xml.EndElement:
// A strict decoder never emits an end element without its start
// element; guard anyway so a decoder change cannot panic here.
if len(openElements) == 0 {
return nil, errors.New("xml: unexpected end element")
}
raw.kind = tokenEndElement
startIndex := openElements[len(openElements)-1]
openElements = openElements[:len(openElements)-1]
tokens[startIndex].match = len(tokens)
case xml.CharData:
raw.kind = tokenCharData
default: // xml.Comment, xml.ProcInst, xml.Directive
raw.kind = tokenOther
}
tokens = append(tokens, raw)
pos = end
}
// A strict decoder reports unclosed elements as a syntax error before
// returning io.EOF; guard anyway so truncated output is impossible.
if len(openElements) != 0 {
return nil, errors.New("xml: unexpected EOF: unclosed element")
}
return tokens, nil
}
// prettyPrintXML reindents xmlContent so structural elements (presentation,
// slide, shape, style, ...) each sit on their own line. The server returns
// XML as a single unbroken line, and this is what makes the --raw and
// --output text surfaces readable; the JSON envelope path never calls it
// (see outputSlidesXMLGetContent).
//
// Offset-slicing invariant: encoding/xml serves purely as a tokenizer, and
// every byte of the output is either a verbatim slice of the input or an
// inserted "\n"+indent run between the children of a structural element.
// Nothing is parsed-and-reserialized, so CDATA sections, whitespace
// character references in any spelling (&#32;, &#x20;, &#0009;, &#13;,
// &#10;, ...), entity lexical forms, attribute quoting, and in-tag
// whitespace all survive byte-for-byte.
//
// Reindentation never enters a textBearingTags element and never touches a
// leaf element (one with no element children), so document text — including
// whitespace-only leaves such as <title> </title> — is never altered.
func prettyPrintXML(xmlContent string) (string, error) {
tokens, err := tokenize(xmlContent)
if err != nil {
return "", err
}
// The decoder tolerates element-free input (plain text, a lone comment,
// nothing at all). A document without a root element is not XML the
// formatter should claim success on; erroring routes it to the
// original-content fallback instead of reporting pretty_printed: true.
if !slices.ContainsFunc(tokens, func(t rawToken) bool { return t.kind == tokenStartElement }) {
return "", errors.New("xml: no root element")
}
var out strings.Builder
out.Grow(len(xmlContent) + len(xmlContent)/8)
reindented := false
for i := 0; i < len(tokens); {
token := tokens[i]
if token.kind == tokenStartElement {
if reindented {
// Any top-level element after the first is copied verbatim;
// well-formed XML has a single root, so this arm only runs
// on technically invalid multi-root input the decoder
// happens to tolerate.
out.WriteString(xmlContent[token.start:tokens[token.match].end])
} else {
writeElement(&out, xmlContent, tokens, i, 0)
reindented = true
}
i = token.match + 1
continue
}
// Document-level prolog and epilog (XML declaration, DOCTYPE,
// comments, whitespace) pass through verbatim.
out.WriteString(xmlContent[token.start:token.end])
i++
}
formatted := out.String()
if !strings.HasSuffix(formatted, "\n") {
formatted += "\n"
}
return formatted, nil
}
// writeElement emits the element whose start token is tokens[startIndex],
// indented as if at the given depth (two spaces per level).
//
// Text-bearing elements and leaf elements (no element children) are emitted
// as a single verbatim input slice from open tag through close tag; for a
// self-closing tag the synthesized end token is zero-width and the slice is
// exactly the open tag. Structural elements (at least one element child,
// not text-bearing) are reindented: text children that are pure literal
// whitespace are dropped as pre-existing formatting, "\n"+indent is
// inserted before every element, comment, and processing-instruction child,
// kept text children stay glued in place with no indentation around them,
// and the close tag moves to its own line unless the last kept child is
// text.
//
// The whitespace-only test runs on the child's RAW source bytes: a
// character reference (&#32;) or a CDATA section is not literal whitespace
// there, so it is kept and its lexical form survives.
func writeElement(out *strings.Builder, input string, tokens []rawToken, startIndex, depth int) {
start := tokens[startIndex]
end := tokens[start.match]
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
out.WriteString(input[start.start:end.end])
return
}
out.WriteString(input[start.start:start.end])
childIndent := "\n" + strings.Repeat(" ", depth+1)
lastKeptIsText := false
for i := startIndex + 1; i < start.match; {
child := tokens[i]
switch child.kind {
case tokenCharData:
if !isAllWhitespace(input[child.start:child.end]) {
out.WriteString(input[child.start:child.end])
lastKeptIsText = true
}
i++
case tokenStartElement:
out.WriteString(childIndent)
writeElement(out, input, tokens, i, depth+1)
lastKeptIsText = false
i = child.match + 1
default: // comment, processing instruction, directive
out.WriteString(childIndent)
out.WriteString(input[child.start:child.end])
lastKeptIsText = false
i++
}
}
if !lastKeptIsText {
out.WriteString("\n")
out.WriteString(strings.Repeat(" ", depth))
}
out.WriteString(input[end.start:end.end])
}
// hasElementChild reports whether the element starting at tokens[startIndex]
// has at least one direct element child. The first start-element token that
// appears before the matching end token is necessarily a direct child, so a
// linear scan without depth tracking suffices.
func hasElementChild(tokens []rawToken, startIndex int) bool {
for i := startIndex + 1; i < tokens[startIndex].match; i++ {
if tokens[i].kind == tokenStartElement {
return true
}
}
return false
}
// isAllWhitespace reports whether s is non-empty and consists only of
// literal XML whitespace bytes (space, tab, CR, LF). It is applied to raw
// source bytes, where character references and CDATA markers count as
// non-whitespace by construction.
func isAllWhitespace(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
switch s[i] {
case ' ', '\t', '\n', '\r':
default:
return false
}
}
return true
}

View File

@@ -0,0 +1,416 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"os"
"strings"
"testing"
)
// The pure-function contract tests for prettyPrintXML (golden strings,
// whitespace character references, leaf whitespace, CDATA, idempotency,
// malformed rejection) live in slides_xml_get_test.go, unchanged from the
// original etree-based implementation. This file adds engine-level cases
// specific to the offset-slicing implementation.
func TestPrettyPrintXMLGoldenPresentation(t *testing.T) {
input := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
want := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLGoldenSlide(t *testing.T) {
input := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
want := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
// TestPrettyPrintXMLRejectsMalformedInputTable pins that the whole document
// is decoded before anything is emitted: even a late syntax error yields no
// partial output, only the error the fallback path reports.
func TestPrettyPrintXMLRejectsMalformedInputTable(t *testing.T) {
tests := []struct {
name string
input string
}{
{"mismatched close tag", `<presentation><slide></presentation>`},
{"unclosed slide from fallback test", `<slide><data></slide>`},
{"invalid control character", "<presentation><title>\x0b</title><slide/></presentation>"},
{"unclosed root", `<presentation><slide/>`},
{"undefined entity", `<presentation><title>&nbsp;</title></presentation>`},
{"bare close tag", `</presentation>`},
{"unescaped cdata terminator in text", `<presentation><title>a]]>b</title></presentation>`},
{"late error after valid prefix", `<presentation><slide/><slide/><slide id=></presentation>`},
{"empty input", ``},
{"whitespace-only input", ` `},
{"plain text without markup", `hello`},
{"comment-only document", `<!-- only a comment -->`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err == nil {
t.Fatalf("prettyPrintXML(%q) = %q, want error", tt.input, got)
}
if got != "" {
t.Fatalf("prettyPrintXML(%q) returned partial output %q alongside error %v", tt.input, got, err)
}
})
}
}
// TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText pins that user content
// resembling the previous implementation's masking placeholders
// (LARKCLI_XML_WHITESPACE_REFERENCE_<n>_) flows through untouched now that
// no masking exists at all.
func TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "placeholder-shaped text in p",
input: `<content><p>LARKCLI_XML_WHITESPACE_REFERENCE_0_&#32;end</p></content>`,
want: "<content>\n <p>LARKCLI_XML_WHITESPACE_REFERENCE_0_&#32;end</p>\n</content>\n",
},
{
name: "placeholder-shaped text in leaf",
input: `<presentation><title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title><slide/></presentation>`,
want: "<presentation>\n <title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title>\n <slide/>\n</presentation>\n",
},
{
name: "placeholder-shaped attribute value",
input: `<presentation><slide note="LARKCLI_XML_WHITESPACE_REFERENCE_0_"><shape/></slide></presentation>`,
want: "<presentation>\n <slide note=\"LARKCLI_XML_WHITESPACE_REFERENCE_0_\">\n <shape/>\n </slide>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestPrettyPrintXMLStructuralTable covers comments, processing
// instructions, prolog/DOCTYPE, mixed text between structural children,
// CRLF pre-formatting, and multi-byte UTF-8 around offset boundaries.
// Expected outputs were verified byte-identical against the previous
// etree-based implementation via a differential probe.
func TestPrettyPrintXMLStructuralTable(t *testing.T) {
tests := []struct {
name string
input string
want string
// wantSecond is the expected output of formatting the output again.
// Usually equal to want (idempotent); the mixed-content rows pin the
// one known non-idempotent shape, where kept text merges with the
// inserted indent on reparse — byte-identical to the previous
// implementation's behavior on the same inputs. Real SML structural
// elements carry no mixed text, so the contract's idempotency
// guarantee is unaffected.
wantSecond string
}{
{
name: "comment child is indented like an element",
input: `<presentation><!-- deck notes --><slide/></presentation>`,
want: "<presentation>\n <!-- deck notes -->\n <slide/>\n</presentation>\n",
},
{
name: "processing instruction child is indented like an element",
input: `<presentation><?pi data?><slide/></presentation>`,
want: "<presentation>\n <?pi data?>\n <slide/>\n</presentation>\n",
},
{
name: "xml declaration prolog stays glued to the root",
input: `<?xml version="1.0" encoding="UTF-8"?><presentation><slide/></presentation>`,
want: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presentation>\n <slide/>\n</presentation>\n",
},
{
name: "prolog with doctype and trailing newline preserved verbatim",
input: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation><slide/></presentation>\n",
want: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation>\n <slide/>\n</presentation>\n",
},
{
name: "document-level trailing comment preserved verbatim",
input: "<presentation><slide/></presentation><!-- tail -->",
want: "<presentation>\n <slide/>\n</presentation><!-- tail -->\n",
},
{
name: "kept mixed text glues to previous sibling and close tag",
input: `<data>x<child/>y</data>`,
want: "<data>x\n <child/>y</data>\n",
wantSecond: "<data>x\n \n <child/>y</data>\n",
},
{
name: "kept mixed text does not suppress indent of next element",
input: `<data>x<child/>y<child/></data>`,
want: "<data>x\n <child/>y\n <child/>\n</data>\n",
wantSecond: "<data>x\n \n <child/>y\n \n <child/>\n</data>\n",
},
{
name: "pre-existing CRLF formatting is dropped and rebuilt",
input: "<presentation>\r\n\t<slide/>\r\n</presentation>",
want: "<presentation>\n <slide/>\n</presentation>\n",
},
{
name: "multi-byte UTF-8 text and attributes keep exact bytes",
input: `<presentation><title>原生图表 📊 Chart</title><slide 备注="中文värde"><shape/></slide></presentation>`,
want: "<presentation>\n <title>原生图表 📊 Chart</title>\n <slide 备注=\"中文värde\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "namespace-prefixed p is still text-bearing",
input: `<content xmlns:sml="urn:x"><sml:p><span>a</span>&#32;<span>b</span></sml:p></content>`,
want: "<content xmlns:sml=\"urn:x\">\n <sml:p><span>a</span>&#32;<span>b</span></sml:p>\n</content>\n",
},
{
name: "already formatted input is preserved",
input: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
want: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
wantSecond := tt.wantSecond
if wantSecond == "" {
wantSecond = tt.want
}
again, err := prettyPrintXML(got)
if err != nil {
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
}
if again != wantSecond {
t.Fatalf("second pass:\nonce: %q\ntwice: %q\nwant: %q", got, again, wantSecond)
}
})
}
}
// TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged pins the cases where
// slicing original bytes intentionally differs from the previous
// etree-based parse-and-reserialize implementation. Each case preserves the
// input MORE faithfully than before; none is covered by the original
// contract tests. The etree field records the old output for the record.
func TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged(t *testing.T) {
tests := []struct {
name string
input string
want string // current behavior: original bytes preserved
etree string // what the etree-based implementation produced
}{
{
name: "whitespace-only CDATA between structural children is kept",
input: `<data><![CDATA[ ]]><child/></data>`,
want: "<data><![CDATA[ ]]>\n <child/>\n</data>\n",
etree: "<data>\n <child/>\n</data>\n",
},
{
name: "empty element with explicit close tag is not collapsed",
input: `<slide><data></data><shape/></slide>`,
want: "<slide>\n <data></data>\n <shape/>\n</slide>\n",
etree: "<slide>\n <data/>\n <shape/>\n</slide>\n",
},
{
name: "non-whitespace character reference keeps its lexical form",
input: `<presentation><title>&#65;&amp;&#x4E2D;</title><slide/></presentation>`,
want: "<presentation>\n <title>&#65;&amp;&#x4E2D;</title>\n <slide/>\n</presentation>\n",
etree: "<presentation>\n <title>A&amp;中</title>\n <slide/>\n</presentation>\n",
},
{
name: "single-quoted attributes keep their quoting",
input: `<presentation><slide id='s1'><shape/></slide></presentation>`,
want: "<presentation>\n <slide id='s1'>\n <shape/>\n </slide>\n</presentation>\n",
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "in-tag whitespace is preserved verbatim",
input: "<presentation><slide id=\"s1\" ><shape/></slide ></presentation>",
want: "<presentation>\n <slide id=\"s1\" >\n <shape/>\n </slide >\n</presentation>\n",
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "literal > in leaf text is not re-escaped",
input: `<presentation><title>a>b</title><slide/></presentation>`,
want: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
etree: "<presentation>\n <title>a&gt;b</title>\n <slide/>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
if tt.want == tt.etree {
t.Fatalf("case is not a divergence: want == etree == %q", tt.want)
}
again, err := prettyPrintXML(got)
if err != nil {
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
}
if again != got {
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", got, again)
}
})
}
}
// loadChartDemo reads the real-world chart demo shipped with the
// lark-slides skill (~60KB, pretty-printed): the closest in-repo stand-in
// for a full presentation read.
func loadChartDemo(t testing.TB) string {
t.Helper()
data, err := os.ReadFile("../../skills/lark-slides/references/slides_chart_demo.xml")
if err != nil {
t.Fatalf("read chart demo fixture: %v", err)
}
return string(data)
}
// minifyXML strips whitespace-only text children of structural (non
// text-bearing, element-bearing) elements — the exact text nodes
// prettyPrintXML treats as disposable formatting — producing the
// single-line element shape the slides server actually returns.
// Document-level tokens (prolog, trailing newline) pass through verbatim,
// because the formatter preserves them verbatim too.
func minifyXML(t testing.TB, input string) string {
t.Helper()
tokens, err := tokenize(input)
if err != nil {
t.Fatalf("tokenize for minify: %v", err)
}
var out strings.Builder
var emitElement func(startIndex int)
emitElement = func(startIndex int) {
start := tokens[startIndex]
end := tokens[start.match]
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
out.WriteString(input[start.start:end.end])
return
}
out.WriteString(input[start.start:start.end])
for i := startIndex + 1; i < start.match; {
child := tokens[i]
switch child.kind {
case tokenCharData:
if !isAllWhitespace(input[child.start:child.end]) {
out.WriteString(input[child.start:child.end])
}
i++
case tokenStartElement:
emitElement(i)
i = child.match + 1
default:
out.WriteString(input[child.start:child.end])
i++
}
}
out.WriteString(input[end.start:end.end])
}
for i := 0; i < len(tokens); {
token := tokens[i]
if token.kind == tokenStartElement {
emitElement(i)
i = token.match + 1
continue
}
out.WriteString(input[token.start:token.end])
i++
}
return out.String()
}
// TestPrettyPrintXMLChartDemoFixture formats the real chart demo both as
// shipped (pretty-printed) and minified to the single-line shape the server
// returns; both must converge on the same idempotent output.
func TestPrettyPrintXMLChartDemoFixture(t *testing.T) {
original := loadChartDemo(t)
formattedOriginal, err := prettyPrintXML(original)
if err != nil {
t.Fatalf("prettyPrintXML(original): %v", err)
}
twice, err := prettyPrintXML(formattedOriginal)
if err != nil {
t.Fatalf("prettyPrintXML(second pass): %v", err)
}
if twice != formattedOriginal {
t.Fatal("prettyPrintXML is not idempotent on the chart demo fixture")
}
minified := minifyXML(t, original)
if strings.Contains(minified, ">\n <") {
t.Fatalf("minified fixture still contains structural indentation: %q", minified[:200])
}
// Only the doc-level newline after the XML declaration and the trailing
// newline may remain; the whole element tree must be one line.
if got := strings.Count(minified, "\n"); got > 2 {
t.Fatalf("minified fixture has %d newlines, want <= 2", got)
}
formattedMinified, err := prettyPrintXML(minified)
if err != nil {
t.Fatalf("prettyPrintXML(minified): %v", err)
}
// Formatting drops exactly the whitespace minification dropped, so both
// paths must converge on the same output.
if formattedMinified != formattedOriginal {
t.Fatal("format(minified) != format(original) for the chart demo fixture")
}
if !strings.Contains(formattedMinified, "\n <slide>") {
t.Fatal("formatted chart demo lacks expected slide indentation")
}
}
func BenchmarkPrettyPrintXMLChartDemoMinified(b *testing.B) {
minified := minifyXML(b, loadChartDemo(b))
b.SetBytes(int64(len(minified)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := prettyPrintXML(minified); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkPrettyPrintXMLChartDemoPreformatted(b *testing.B) {
original := loadChartDemo(b)
b.SetBytes(int64(len(original)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := prettyPrintXML(original); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -112,8 +112,7 @@ metadata:
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
## 表单与视图细节

View File

@@ -8,7 +8,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`
- `+record-batch-create``rows``CellValue[][]`,列顺序由 `fields` 决定
- `+record-batch-create`使用 `create_records`,其每个元素都是 `Map<FieldNameOrID, CellValue>`
- `+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

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

View File

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

View File

@@ -36,7 +36,7 @@ func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
"base", "+record-batch-create",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
"--json", `{"create_records":[{"Name":"alpha","Status":"Open","Score":10},{"Name":"beta","Status":"Open","Score":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.fields`; `--json.rows` | seeds heterogeneous live workflow records |
| ✓ | 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-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 |