Compare commits

...

6 Commits

Author SHA1 Message Date
zhanghuanxu
44be8c986f fix(slides): close text-overlap false negatives and unify z-order checks
Fix missed overflow and occlusion cases in xml_text_overlap_lint: CJK
ambiguous-width and percent glyph width estimation, chart-vs-text
occlusion, full-canvas background-image exemption, and severity masking
in the width/height overflow dedupe. Consolidate the scattered raw
paint-order comparisons into is_drawn_behind / is_drawn_in_front_of so
stacking direction is decided in one place, with contract tests that
turn red if the fixes are reverted.
2026-07-31 18:30:39 +08:00
zhanghuanxu
a849ac3449 fix(slides): detect width-induced text wrap in xml_text_overlap_lint
The height-only check (text_may_overflow_shape) misses shapes that wrap
because their box is too narrow, not too short. Added a new width-axis
detector that:

- Flags single-line short labels/metrics whose estimated width exceeds
  the content box (0.85 risk band for latin runs, 1.18 tolerance for
  plain metrics, exact fit for pure CJK).
- Works independently of autoFit (shape-auto-fit only grows height).
- Preserves internal whitespace (e.g. "autofix      87%") so spaces
  are not collapsed away.
- Deduplicates with the height check so the same shape is not
  double-reported under the shared text_may_overflow_shape code.

The two axes share code="text_may_overflow_shape" and are distinguished
by overflow_axis="height"|"width". Regression test covers all three
real false-negative cases (bMP/bMp/bMm) plus negative controls.
2026-07-31 18:30:39 +08:00
zhanghuanxu
47a46658c8 fix(slides): remove order exemption in image-text occlusion detection
The order-based skip (`image.order <= text.order`) allowed images that
appear before text in XML to silently cover text glyphs. Remove it so
any geometric overlap is reported regardless of XML element order.

Also update the error hint to no longer suggest reordering XML as a
fix, since that no longer works.

Add a regression test verifying the new behavior.
2026-07-31 18:30:39 +08:00
zhanghuanxu
11f34b3e85 fix(slides): detect text-line overlap in xml_text_overlap_lint 2026-07-31 18:30:39 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
31 changed files with 2415 additions and 202 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
Tips: []string{
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
"Each new question creates a field in the form's table; question IDs are field IDs.",
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
api := common.NewDryRunAPI().
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
Set("form_id", runtime.Str("form-id")).
Body(map[string]interface{}{"questions": questions})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
questions, err := parseFormQuestionsCreate(questionsJSON)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST",
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
if questions == nil {
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
}
if len(questions) > 10 {
return nil, baseValidationErrorf("--questions must contain at most 10 items")
}
for i, question := range questions {
item, ok := question.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
}
title, ok := item["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
}
questionType, ok := item["type"].(string)
if !ok || strings.TrimSpace(questionType) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
}
}
return questions, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
)
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
for _, want := range []string{
"+form-questions-list",
"verified empty form can create directly",
"question IDs are field IDs",
"explicitly requests a separate same-title question",
"+form-questions-update",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}

View File

@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapDriveNetworkErr(err, "download failed: %s", err)
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
}
defer resp.Body.Close()

View File

@@ -5,6 +5,8 @@ package drive
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
// +download intact while giving callers a preview-based path to view content.
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
return err
}
if strings.Contains(problem.Hint, "drive +preview") {
return err
}
hint := driveDownloadForbiddenPreviewHint()
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = hint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
return err
}
func driveDownloadForbiddenPreviewHint() string {
const tokenArg = "<FILE_TOKEN>"
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."

View File

@@ -1580,6 +1580,84 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_403/download",
Status: http.StatusForbidden,
RawBody: []byte("permission denied"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_403",
"--output", "blocked.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 403 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryNetwork {
t.Fatalf("category=%q, want network", problem.Category)
}
if problem.Code != http.StatusForbidden {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
}
if !strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
}
if strings.Contains(problem.Hint, "file_403") {
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
}
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
}
}
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_missing/download",
Status: http.StatusNotFound,
RawBody: []byte("not found"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_missing",
"--output", "missing.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 404 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
}
if strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
}
}
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="////"`},

View File

@@ -16,13 +16,13 @@ import (
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Description: "View or download Drive file content, or list and fetch available preview artifacts",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
@@ -40,6 +40,25 @@ var DrivePreview = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
if requestedType == "source_file" {
downloadParams := map[string]interface{}{
"preview_type": drivePreviewTypeSourceFile,
}
if version != "" {
downloadParams["version"] = version
}
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download the source file artifact").
Params(downloadParams).
Set("file_token", fileToken).
Set("mode", "download").
Set("requested_type", requestedType).
Set("selected_type", "source_file").
Set("selected_type_code", drivePreviewTypeSourceFile).
Set("output", runtime.Str("output"))
}
body := map[string]interface{}{}
if version != "" {
body["version"] = version
@@ -67,7 +86,7 @@ var DrivePreview = common.Shortcut{
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("requested_type", requestedType).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -82,9 +101,25 @@ var DrivePreview = common.Shortcut{
body["version"] = version
}
if requestedType == "source_file" {
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = "source_file"
runtime.Out(result, nil)
return nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
if runtime.Bool("list-only") {
return withDrivePreviewSourceFileHint(err)
}
return err
}
if runtime.Bool("list-only") {

View File

@@ -27,6 +27,8 @@ const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
drivePreviewTypeSourceFile = "16"
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
)
type drivePreviewCandidate struct {
@@ -88,7 +90,9 @@ var drivePreviewMimeToExt = map[string]string{
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/x-markdown": ".md",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
if err != nil {
return nil, err
}
@@ -492,8 +496,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
@@ -522,6 +526,32 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
}
}
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
if drivePreviewOutputIsDirectory(runtime, outputPath) {
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
return filepath.Join(outputPath, fileName), resolution
}
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
}
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
if name == "" {
name = driveDownloadNormalizeFileName(fallbackName)
}
name = sanitizeExportFileName(name, "preview")
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
return name, resolution
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
@@ -556,6 +586,15 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if fallbackExt == "" {
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
return normalizedPath, nil
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
@@ -804,6 +843,36 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
// API failures without changing their classification or server diagnostics.
func withDrivePreviewSourceFileHint(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI {
return err
}
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
return err
}
if strings.Contains(problem.Hint, "--type source_file") {
return err
}
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
return err
}
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = drivePreviewSourceFileHint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
return err
}
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
return problem != nil &&
problem.Code == 1 &&
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {

View File

@@ -147,6 +147,63 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
}
}
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
// source_file downloads the source file artifact without first fetching preview
// candidates.
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
Status: 200,
Body: []byte("# markdown\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
"Content-Type": []string{"text/plain; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_source",
"--type", "source_file",
"--output", "artifacts/",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["requested_type"]; ok {
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
gotBody, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
}
if string(gotBody) != "# markdown\n" {
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
@@ -434,6 +491,72 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
}
}
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
// dry-run documents the direct source artifact download path.
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source_file",
"version": "7",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "source_file" {
t.Fatalf("requested_type=%v, want source_file", got)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
}
api, _ := data["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("len(api)=%d, want 1", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "GET" {
t.Fatalf("method=%v, want GET", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
t.Fatalf("url=%v, want preview_download", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
}
if got := params["version"]; got != "7" {
t.Fatalf("params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
// explicit source_file request bypasses preview_result.
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
@@ -612,6 +735,135 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
}
}
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
// failures keep server diagnostics while guiding callers to source_file.
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
Body: map[string]interface{}{
"code": 1,
"msg": "fail:mGetFilePreviewCore failed",
"log_id": "log-preview-result",
"error": map[string]interface{}{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
"details": []interface{}{
map[string]interface{}{"value": "server preview_result detail"},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_markdown",
"--list-only",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected preview_result error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category=%q, want api", problem.Category)
}
if problem.Code != 1 {
t.Fatalf("code=%d, want 1", problem.Code)
}
if problem.LogID != "log-preview-result" {
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
}
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
}
if !strings.Contains(problem.Hint, "server preview_result detail") {
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
}
}
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
// errors are not reframed as source_file recovery.
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Hint != "" {
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
}
if !problem.Retryable {
t.Fatal("retryable=false, want true")
}
}
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
// only rewrites eligible API errors and preserves existing source_file hints.
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
plainErr := errors.New("plain failure")
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
}
for _, tt := range []struct {
name string
err *errs.APIError
want string
}{
{
name: "already has source file hint",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
want: "rerun with --type source_file --output <path>",
},
{
name: "candidate core failure empty hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
want: drivePreviewSourceFileHint,
},
{
name: "candidate core failure whitespace hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
want: drivePreviewSourceFileHint,
},
{
name: "generic server error",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
want: "",
},
{
name: "not found",
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
want: "",
},
{
name: "invalid parameters",
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
want: "",
},
} {
t.Run(tt.name, func(t *testing.T) {
gotErr := withDrivePreviewSourceFileHint(tt.err)
if gotErr != tt.err {
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
}
problem, ok := errs.ProblemOf(gotErr)
if !ok {
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
}
if problem.Hint != tt.want {
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
}
})
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
@@ -721,6 +973,21 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
header.Set("Content-Disposition", `attachment; filename="README.md"`)
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
@@ -751,7 +1018,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
@@ -771,6 +1038,20 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
if err := os.Mkdir("artifacts", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
sourceHeader := http.Header{}
sourceHeader.Set("Content-Type", "text/plain")
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
}
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
@@ -791,7 +1072,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
@@ -876,7 +1157,6 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)

View File

@@ -32,6 +32,7 @@ const (
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
markdownSourceFilePreviewType = "16"
)
var markdownUploadRetryBackoffs = []time.Duration{
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
query, err := markdownSourceFilePreviewQuery("", "")
if err != nil {
return nil, err
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
})
if err != nil {
return nil, wrapMarkdownDownloadError(err)
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
query, err := markdownSourceFilePreviewQuery(version, versionParam)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
}
resp, err := runtime.DoAPIStream(ctx, req)
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
query := larkcore.QueryParams{
"preview_type": []string{markdownSourceFilePreviewType},
}
if version != "" {
query["version"] = []string{version}
}
return query, nil
}
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
params := map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
}
if version != "" {
params["version"] = version
}
return params, nil
}
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
if err != nil {
// Shortcut validation runs before DryRun. If a caller bypasses that
// contract, preserve the supplied value instead of silently dropping it.
params = map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
"version": version,
}
}
return params
}
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
if version == "" {
return nil
}
if strings.TrimSpace(version) != "" {
return nil
}
if flagName == "" {
flagName = "--version"
}
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath

View File

@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
return err
}
if !markdownDiffVersionRe.MatchString(value) {
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the specified remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the base remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the target remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.ToVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
if err != nil {
return "", "", err
}
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
}
if err != nil {
return err

View File

@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
}
}
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "from version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", " \t",
"--file", "./local.md",
},
wantParam: "--from-version",
},
{
name: "to version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", " ",
},
wantParam: "--to-version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
requireMarkdownValidationParam(t, err, tt.wantParam)
if !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("expected empty version validation error, got %v", err)
}
})
}
}
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
version := " 7633658129540910621 "
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
}
if got := query["version"]; len(got) != 1 || got[0] != version {
t.Fatalf("query version = %#v, want original %q", got, version)
}
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
}
if got := params["version"]; got != version {
t.Fatalf("dry-run version = %#v, want original %q", got, version)
}
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
requireMarkdownValidationParam(t, err, "--from-version")
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
requireMarkdownValidationParam(t, err, "--to-version")
}
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())

View File

@@ -5,14 +5,10 @@ package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
if err != nil {
return wrapMarkdownDownloadError(err)
return err
}
defer resp.Body.Close()

View File

@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the current Markdown source file preview artifact").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").

View File

@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
t.Fatalf("download preview_type = %#v", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello world\n"),
})
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello\n"),
})
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}

View File

@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.3
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive认证/授权转 lark-shared。"
metadata:
requires:
bins: ["lark-cli"]
@@ -23,14 +23,15 @@ metadata:
不要使用本 skill
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 把本地文件导入成 Base或将 Base 导出为本地文件,转 `lark-drive`
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先`lark-cli drive +import --type bitable`导入完成后再回到 Base 命令。
- 本地文件与 Base 之间的导入/导出`lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
@@ -49,6 +50,7 @@ metadata:
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
@@ -63,8 +65,9 @@ metadata:
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md)删除前确认目标表单 |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list``+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`删除前确认目标表单 |
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
@@ -116,6 +119,9 @@ metadata:
## 表单与视图细节
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list``+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id``+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`
- 表单问题由数据表字段承载question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`

View File

@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1.`+form-questions-list` 查看现有问题
2. 确认要添加的问题内容
3. 执行命令并报告新建的问题 ID
1.确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`
2. `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
## 参考

View File

@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Goal | Command | Notes |
|------|---------|-------|
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
## Required order
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
## Safety boundaries
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.

View File

@@ -154,12 +154,34 @@
"table_rule_map": {
"订单表": {
"perm": "edit",
"view_rule": { "..." : "..." },
"record_rule": { "..." : "..." },
"field_rule": { "..." : "..." }
"view_rule": {
"allow_edit": true,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": ["add", "delete"],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_edit"
}
},
"用户表": {
"perm": "read_only"
"perm": "read_only",
"view_rule": {
"allow_edit": false,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": [],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_read"
}
},
"内部表": {
"perm": "no_perm"
}
}
}
@@ -172,7 +194,11 @@
| `record_rule` | RecordRule | 记录权限配置 |
| `field_rule` | FieldRule | 字段权限配置 |
**注意**: 当 `perm``no_perm` 时,`view_rule``record_rule``field_rule` 均无须再设置。
**`+role-create` 硬约束**:
-`perm``no_perm` 时,不要设置 `view_rule``record_rule``field_rule`
-`perm` 为其他值时,必须同时提供完整的 `view_rule``record_rule``field_rule`,缺少任意一项都会导致创建失败。
- `+role-update` 是 delta merge只提交要修改的字段不要为局部更新补造未变更配置。
---

View File

@@ -43,7 +43,7 @@ metadata:
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件 PDF / HTML / 文本 / 图片等预览产物。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |

View File

@@ -25,6 +25,10 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
file_token
```
## 排障
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令

View File

@@ -2,15 +2,24 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
- 只想看候选项时,用 `--list-only`
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image`
- 想下载时,必须显式传 `--type``--output`
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容资源不存在、token 无效等终态错误需要先修正输入
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
### 命令
```bash
# 查看文件内容
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type source_file \
--output ./artifacts/source
# 列出可用预览候选项
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
@@ -78,6 +87,7 @@ lark-cli drive +preview \
- 不传 `--list-only` 时,必须显式传 `--type``--output`
- 不会隐式选择“第一个候选项”作为默认下载目标
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名

File diff suppressed because it is too large Load Diff

View File

@@ -1004,9 +1004,17 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
# Two caption/label pairs overlap; blV also trips the width-wrap rule (its 15-char
# caption renders wider than its 150px box), which is an intentional error here.
self.assertEqual(result["summary"]["error_count"], 3)
self.assertIn(("blY", "blV"), overlap_pairs)
self.assertIn(("blQ", "blS"), overlap_pairs)
wrap_ids = {
issue["elements"][0]
for issue in result["slides"][0]["issues"]
if issue.get("overflow_axis") == "width"
}
self.assertEqual(wrap_ids, {"blV"})
def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1119,6 +1127,42 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_detects_short_label_that_wraps_by_width(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="near-fit" type="text" topLeftX="40" topLeftY="40" width="176" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true"><p>Slides 87% </p></content>
</shape>
<shape id="under-measured" type="text" topLeftX="40" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体"><p>Docs 99%</p></content>
</shape>
<shape id="auto-fit-spaced" type="text" topLeftX="300" topLeftY="40" width="227" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true" autoFit="shape-auto-fit"><p>autofix 87% </p></content>
</shape>
<shape id="no-wrap-label" type="text" topLeftX="300" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体" wrap="false"><p>Docs 99%</p></content>
</shape>
<shape id="comfortable" type="text" topLeftX="600" topLeftY="40" width="300" height="60">
<content fontSize="24" fontFamily="思源黑体"><p>OK</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"] if issue.get("overflow_axis") == "width"
]
wrap_ids = {issue["elements"][0] for issue in wrap_issues}
# The three real false-negatives are caught, independent of autoFit and collapsed spaces.
self.assertEqual(wrap_ids, {"near-fit", "under-measured", "auto-fit-spaced"})
self.assertTrue(all(issue["level"] == "error" for issue in wrap_issues))
self.assertTrue(all(issue["code"] == "text_may_overflow_shape" for issue in wrap_issues))
# wrap="false" opts a run out; a label that comfortably fits is not flagged.
self.assertNotIn("no-wrap-label", wrap_ids)
self.assertNotIn("comfortable", wrap_ids)
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1203,6 +1247,28 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_labeled_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="sheet-success" type="text" topLeftX="520" topLeftY="385" width="180" height="50">
<content textType="headline" fontSize="32" bold="true" autoFit="no-auto-fit">
<p>Sheet 98.5%</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["sheet-success"])
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1223,6 +1289,97 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
def test_lint_xml_reports_cjk_credit_with_em_dashes_wrapping_narrow_box(self) -> None:
# "—— 李白" in a tight author-credit box wraps in the renderer because the two em-dashes render
# full-width inside a CJK run (slides p1: bMW). unicodedata marks em-dash as ambiguous width, so
# a naive Latin-punctuation estimate under-reports the line and misses the wrap. The width check
# must treat ambiguous glyphs as full-width in CJK context (Bucket A4).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="credit" type="text" topLeftX="66" topLeftY="124" width="46" height="18">
<content fontSize="12"><p>—— 李白</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["credit"]
]
# Promoting the em-dashes to full-width makes the run too wide for the box; the renderer then
# wraps it to two lines that also overflow the 18px height, so either the width or the height
# detector may surface it first -- the contract is that the credit is flagged, not which axis.
self.assertEqual(len(wrap_issues), 1)
self.assertIn(wrap_issues[0]["overflow_axis"], {"width", "height"})
def test_lint_xml_keeps_latin_en_dash_range_narrow(self) -> None:
# The ambiguous-width promotion is context-gated: an en-dash in a pure-Latin run ("20202023")
# stays half-width, so a comfortably-sized box must not be reported. Guards A4 from over-firing
# by inflating every dash to full-width regardless of surrounding script.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="range" type="text" topLeftX="80" topLeftY="80" width="140" height="30">
<content fontSize="14"><p>20202023</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(wrap_issues, [])
def test_lint_xml_reports_percent_heavy_run_overflowing_by_full_width_glyph(self) -> None:
# "%" is Unicode half-width (Na) but renders near full-width, so a percentage-heavy run wraps to
# more lines than a naive punct-coefficient estimate and overflows its box height (slides p3:
# bhU "Docs 99%Docs 99%Docs 99%%1"). Measuring "%" at its true advance is what surfaces this.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metrics" type="text" topLeftX="587" topLeftY="60" width="227" height="100">
<content fontSize="30" autoFit="no-auto-fit"><p>Docs 99%Docs 99%Docs 99%%1</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["metrics"]
]
self.assertEqual(len(overflow), 1)
def test_lint_xml_marginal_height_warning_does_not_mask_width_error(self) -> None:
# A short "Slides 87%" label sized so its wrapped two lines graze the box height by <1px yields a
# height *warning*, while the same run is genuinely too wide -> a width *error*. The width error
# must still surface: a marginal height warning must not suppress it via already_flagged_ids
# (slides p3: bMP/bhd). The run is reported once, at error level.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="label" type="text" topLeftX="244" topLeftY="120" width="176" height="79">
<content fontSize="32" bold="true" autoFit="no-auto-fit"><p>Slides 87%</p></content>
</shape>
</data>
</slide>
"""
)
reports = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["label"]
]
self.assertEqual(len(reports), 1)
self.assertEqual(reports[0]["level"], "error")
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1716,10 +1873,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
<shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
<shape type="text" topLeftX="300" topLeftY="80" width="180" height="80">
<content textType="title" fontSize="44"><p>Title</p></content>
</shape>
<shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
<shape type="text" topLeftX="300" topLeftY="170" width="180" height="40">
<content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
</shape>
</data>
@@ -1893,6 +2050,32 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(len(crossing), 1)
def test_lint_xml_reports_horizontal_line_inside_wide_line_spacing_span(self) -> None:
# 3 lines of fontSize 20 at multiple:1.8 give a real 92px glyph span, but the flat
# font_size*1.2 approximation is only 72px. Both boxes centre in the 200px shape, so the flat
# eroded box is ~[246,314] while the spacing-aware eroded box is ~[236,324]. A rule at y=240
# lands in that top margin -- inside the real glyph rows yet outside the flat box -- so it only
# reports once the line-crossing path uses the spacing-aware height (Bucket C, slides p8/p10).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="poem" type="text" topLeftX="80" topLeftY="180" width="360" height="200">
<content fontSize="20" lineSpacing="multiple:1.8"><p>第一行诗句文字</p><p>第二行诗句文字</p><p>第三行诗句文字</p></content>
</shape>
<line id="rule" startX="80" startY="240" endX="220" endY="240">
<border color="rgb(0, 0, 0)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"rule", "poem"}
]
self.assertEqual(len(crossing), 1)
self.assertEqual(crossing[0]["code"], "bbox_overlap")
def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2321,6 +2504,266 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_rotated_text_colliding_with_horizontal_text(self) -> None:
# A 270-rotated label sweeps a vertical footprint that overlaps a nearby horizontal label. With
# rotation-aware glyph boxes the collision is detectable, and because the runs are not parallel
# the overlap ratio is tiny so the absolute-area fallback must flag it (slides p6, Bucket D+E).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="flat" type="text" topLeftX="240" topLeftY="200" width="64" height="24">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
<shape id="spun" type="text" topLeftX="272" topLeftY="232" width="64" height="24" rotation="270">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"flat", "spun"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_still_suppresses_coincident_shadow_text_overlay(self) -> None:
# A drop-shadow duplicate offset by a pixel is an intentional overlay; the coincidence check
# must keep suppressing it even though the text is identical (guards the E1 tightening).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="shadow" type="text" topLeftX="200" topLeftY="200" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
<shape id="fill" type="text" topLeftX="202" topLeftY="202" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"shadow", "fill"}
]
self.assertEqual(collisions, [])
def test_lint_xml_reports_text_overflowing_background_container(self) -> None:
# Text anchored inside a background card whose glyph box spills past the card's bottom edge has
# outgrown the box the author sized for it (slides p7). The card is drawn first (lower z-order),
# so it is the container; the text must surface as text_overflows_container (Bucket B).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="120" height="40">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="205" topLeftY="205" width="110" height="120">
<content fontSize="16"><p>第一行</p><p>第二行</p><p>第三行</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_overflows_container" and set(issue["elements"]) == {"body", "card"}
]
self.assertEqual(len(overflow), 1)
self.assertGreater(overflow[0]["overflow"]["bottom"], 4)
def test_lint_xml_ignores_text_fitting_inside_background_container(self) -> None:
# Text whose glyph box stays inside its background card is fine; the container rule must stay
# silent so tightly-fitted-but-valid cards are not falsely reported.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="200" height="120">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="210" topLeftY="210" width="180" height="40">
<content fontSize="14"><p>短文本</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("text_overflows_container", codes)
def test_lint_xml_reports_free_text_shape_overlapping_table_grid(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling table occludes the cell
# contents (slides p4). The table renders its own text; a stray shape over the grid is an
# accidental overlay, so it must surface as table_covers_text (Bucket B).
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>
<table id="grid" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>A</p></content></td></tr>
</table>
<shape id="stray" type="text" topLeftX="260" topLeftY="240" width="120" height="30">
<content fontSize="16"><p>覆盖表格</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "table_covers_text" and set(issue["elements"]) == {"grid", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_table_with_only_cell_text(self) -> None:
# Cell text is part of the table's own layout and is never extracted as a standalone shape, so
# a table alone must not self-report table_covers_text (guards against a runaway detector).
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>
<table id="solo" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("table_covers_text", codes)
def test_lint_xml_reports_free_text_shape_overlapping_chart(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling chart occludes the chart's
# generated labels and legend (slides p5: a headline dropped onto a pie chart's ring). The chart
# renders its own text; a stray shape over the plot area is an accidental overlay, so it must
# surface as chart_covers_text (Bucket B3).
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>
<chart id="pie" topLeftX="200" topLeftY="60" width="420" height="420">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="stray" type="text" topLeftX="360" topLeftY="120" width="120" height="40">
<content fontSize="32"><p>abc 99%</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "chart_covers_text" and set(issue["elements"]) == {"pie", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_chart_not_overlapping_text(self) -> None:
# A chart and a text shape that sit side by side without their glyph boxes touching must not
# report chart_covers_text (guards the detector from firing on mere co-existence).
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>
<chart id="pie" topLeftX="40" topLeftY="60" width="300" height="300">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="caption" type="text" topLeftX="600" topLeftY="80" width="200" height="40">
<content fontSize="16"><p>Sales breakdown</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("chart_covers_text", codes)
def test_lint_xml_reports_auto_fit_title_growing_onto_body_below(self) -> None:
# A shape-auto-fit title sized for one line wraps to two, growing downward past its authored box
# onto the body text beneath it (slides p9). shape-auto-fit only means the box grows to fit, so
# the grown glyph height -- not the authored height -- is what collides. The body is a tall
# multi-line block so the overlap covers <30% of it: the generic text-text check cannot catch
# this, only the dedicated auto-fit growth detector can (Bucket A3 / auto-fit growth).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="60" width="480" height="200">
<content fontSize="15" verticalAlign="top"><p>1. Marxist Perspective</p><p>Line two of body copy</p><p>Line three of body copy</p><p>Line four of body copy</p><p>Line five of body copy</p><p>Line six of body copy</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_ignores_auto_fit_title_with_space_below(self) -> None:
# An identical wrapping auto-fit title with an empty gap below it grows harmlessly; the check
# must stay silent so ordinary auto-fit growth is not flagged (guards the grown-region area gate).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="300" width="480" height="200">
<content fontSize="15"><p>1. Marxist Perspective</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(collisions, [])
def test_lint_xml_does_not_treat_divider_rule_as_text_background_container(self) -> None:
# A thin horizontal rule under a title is a divider, not a container. Owning a title's grown
# glyph box to a 3px rule and reporting it as text_overflows_container is a false positive
# (slides p9); the line-like guard must keep the divider out of the container candidate set.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rule" type="rect" topLeftX="40" topLeftY="60" width="880" height="3">
<fill><fillColor color="rgba(40,60,120,1)"/></fill>
</shape>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
</data>
</slide>
"""
)
container_hits = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_overflows_container" and "rule" in issue["elements"]
]
self.assertEqual(container_hits, [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2459,6 +2902,79 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
def test_lint_xml_reports_image_text_overlap_even_when_image_precedes_text_in_xml_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<img id="image" src="token" topLeftX="120" topLeftY="120" width="120" height="60"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="220" height="90">
<content fontSize="28" lineSpacing="fixed:34" wrap="false"><p>Quarterly Plan</p></content>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_covers_text")
self.assertEqual(issue["elements"], ["image", "text"])
self.assertIn("no longer overlaps the text glyph area", issue["hint"])
self.assertEqual(result["summary"]["error_count"], 1)
def test_lint_xml_exempts_full_canvas_background_image_behind_text(self) -> None:
# A full-bleed image at the bottom of the z-order is the slide backdrop; text rendered on top of
# it is never occluded (slides p9: bBo fills the whole canvas under the content). It must not be
# reported as image_covers_text (Bucket B5 background-image false positive).
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>
<img id="backdrop" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>On the backdrop</p></content>
</shape>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and "backdrop" in issue["elements"]
]
self.assertEqual(codes, [])
def test_lint_xml_reports_full_canvas_image_drawn_above_text(self) -> None:
# The exemption is z-order aware: a full-canvas image drawn *after* (above) the text really does
# cover it, so it must still be flagged. Guards the backdrop exemption from swallowing real
# occlusions where the image is on top.
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="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>Under the cover</p></content>
</shape>
<img id="cover" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and set(issue["elements"]) == {"cover", "text"}
]
self.assertEqual(len(codes), 1)
def test_stacking_helpers_agree_on_paint_order(self) -> None:
lower = {"order": 1}
upper = {"order": 3}
same = {"order": 3}
# is_drawn_behind and is_drawn_in_front_of are strict and mutually exclusive inverses.
self.assertTrue(xml_text_overlap_lint.is_drawn_behind(lower, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(lower, upper))
self.assertTrue(xml_text_overlap_lint.is_drawn_in_front_of(upper, lower))
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(upper, lower))
# Equal order is neither behind nor in front, so an equal-order sibling never occludes.
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(same, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(same, upper))
class XmlTextOverlapLintDensityTest(unittest.TestCase):
def test_lint_xml_blocks_blank_slide(self) -> None:

View File

@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(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-list",
"--base-token", "basXXXX",
"--table-id", "tblXXXX",
"--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/basXXXX/tables/tblXXXX/forms")
assert.Contains(t, output, `"method": "GET"`)
}

View File

@@ -0,0 +1,108 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestBaseFormQuestionsCreateDryRun(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-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
}
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
setBaseDryRunConfigEnv(t)
tests := []struct {
name string
input string
message string
}{
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
{name: "null", input: "null", message: "must be a non-null JSON array"},
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
}
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{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", tt.input,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
require.Empty(t, result.Stdout)
})
}
}
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(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-questions-create", "--help"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
require.Contains(t, result.Stdout, "+form-questions-list")
require.Contains(t, result.Stdout, "+form-questions-update")
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"path/filepath"
"runtime"
"testing"
"github.com/larksuite/cli/internal/vfs"
"github.com/stretchr/testify/require"
)
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
content, err := vfs.ReadFile(skillPath)
require.NoError(t, err)
skill := string(content)
require.Contains(t, skill, "文件导入/导出转 lark-drive")
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
require.Contains(t, skill, "在线复制走 `+base-copy`")
require.NotContains(t, skill, "--only-schema")
require.NotContains(t, skill, "--output-dir")
require.NotContains(t, skill, "/tmp/")
}

View File

@@ -1,17 +1,21 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 78 leaf commands
- Covered: 22
- Coverage: 28.2%
- Denominator: 87 leaf commands
- Covered: 28
- Coverage: 32.2%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
@@ -34,6 +38,7 @@
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
@@ -50,12 +55,14 @@
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
| | base +form-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
| ✕ | 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-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 |
@@ -64,6 +71,7 @@
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
@@ -78,6 +86,8 @@
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
| ✕ | base +view-get | shortcut | | none | view workflows not covered |

View File

@@ -93,6 +93,55 @@ func TestDrivePreviewDryRun_Download(t *testing.T) {
}
}
// TestDrivePreviewDryRun_SourceFile verifies source_file mode maps to a direct
// source artifact download request.
func TestDrivePreviewDryRun_SourceFile(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "source_file",
"--version", "12",
"--output", "./artifacts/source",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("url=%q, want preview download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.preview_type").String(); got != "16" {
t.Fatalf("preview_type=%q, want 16\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "requested_type").String(); got != "source_file" {
t.Fatalf("requested_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type_code").String(); got != "16" {
t.Fatalf("selected_type_code=%q, want 16\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(t *testing.T) {

View File

@@ -35,6 +35,41 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("source file download", func(t *testing.T) {
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "source_file",
"--output", "./artifacts/report-source",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if gjson.Get(stdout, "data.requested_type").Exists() {
t.Fatalf("requested_type should be omitted from execute output\nstdout:\n%s", stdout)
}
if got := gjson.Get(stdout, "data.selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, stdout)
}
if gjson.Get(stdout, "data.selected_type_code").Exists() {
t.Fatalf("selected_type_code should be omitted from execute output\nstdout:\n%s", stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "source file preview should return output_path")
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if string(data) != sourceContent {
t.Fatalf("source file preview content=%q want %q", string(data), sourceContent)
}
})
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{

View File

@@ -149,11 +149,14 @@ func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
require.Equal(t, "remote_vs_remote", clie2e.DryRunGet(output, "mode").String(), output)
require.Equal(t, int64(2), clie2e.DryRunGet(output, "api.#").Int(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.0.params.preview_type").String(), output)
require.Equal(t, "7633658129540910621", clie2e.DryRunGet(output, "api.0.params.version").String(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.1.params.preview_type").String(), output)
require.Equal(t, "7633658129540910628", clie2e.DryRunGet(output, "api.1.params.version").String(), output)
require.Equal(t, int64(1), clie2e.DryRunGet(output, "context_lines").Int(), output)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
@@ -179,8 +182,9 @@ func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
@@ -224,7 +228,8 @@ func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"output": "./copy.md"`)
}
@@ -305,7 +310,8 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")