Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion shortcuts/drive/drive_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var DriveExport = common.Shortcut{
Flags: []common.Flag{
{Name: "token", Desc: "source document token", Required: true},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
Comment thread
liujinkun2025 marked this conversation as resolved.
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
Expand Down
10 changes: 8 additions & 2 deletions shortcuts/drive/drive_export_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,19 @@ func validateDriveExportSpec(spec driveExportSpec) error {
}

switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown":
case "docx", "pdf", "xlsx", "csv", "markdown", "base":
default:
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown", spec.FileExtension)
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base", spec.FileExtension)
}

if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
}

if spec.FileExtension == "base" && spec.DocType != "bitable" {
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
}

if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
Expand Down Expand Up @@ -367,6 +371,8 @@ func exportFileSuffix(fileExtension string) string {
return ".xlsx"
case "csv":
return ".csv"
case "base":
return ".base"
default:
return ""
}
Expand Down
6 changes: 6 additions & 0 deletions shortcuts/drive/drive_export_common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,10 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
if got := ensureExportFileExtension("report.pdf", "pdf"); got != "report.pdf" {
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
}
if got := ensureExportFileExtension("crm", "base"); got != "crm.base" {
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "crm.base")
}
if got := exportFileSuffix("base"); got != ".base" {
t.Fatalf("exportFileSuffix(base) = %q, want %q", got, ".base")
}
}
97 changes: 97 additions & 0 deletions shortcuts/drive/drive_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package drive

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
Expand Down Expand Up @@ -45,6 +46,20 @@ func TestValidateDriveExportSpec(t *testing.T) {
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pdf", SubID: "tbl_1"},
wantErr: "--sub-id is only used",
},
{
name: "base bitable ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
},
{
name: "base non bitable rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
wantErr: "only supports --doc-type bitable",
},
{
name: "unknown file extension rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "rtf"},
wantErr: "invalid --file-extension",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -185,6 +200,88 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}

func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_base"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_base",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_base",
"file_name": "crm",
"file_extension": "base",
"type": "bitable",
"file_size": 8,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_base/download",
Status: 200,
RawBody: []byte("snapshot"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename="crm.base"`},
},
})

tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)

prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})

err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "bitable123",
"--doc-type", "bitable",
"--file-extension", "base",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["file_extension"] != "base" {
t.Fatalf("export_tasks body file_extension = %v, want %q", createBody["file_extension"], "base")
}
if createBody["type"] != "bitable" {
t.Fatalf("export_tasks body type = %v, want %q", createBody["type"], "bitable")
}

data, err := os.ReadFile(filepath.Join(tmpDir, "crm.base"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "snapshot" {
t.Fatalf("downloaded content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_extension": "base"`) {
t.Fatalf("stdout missing base file_extension: %s", stdout.String())
}
}

func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/drive/drive_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var DriveImport = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base; large files auto use multipart upload; .base is capped at 20MB)", Required: true},
Comment thread
liujinkun2025 marked this conversation as resolved.
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
Expand Down
7 changes: 5 additions & 2 deletions shortcuts/drive/drive_import_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var driveImportExtToDocTypes = map[string][]string{
"xlsx": {"sheet", "bitable"},
"xls": {"sheet"},
"csv": {"sheet", "bitable"},
"base": {"bitable"},
}

// driveImportSpec contains the user-facing import inputs after normalization.
Expand Down Expand Up @@ -143,7 +144,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls":
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
case "xlsx":
return driveImport800MBFileSizeLimit, true
Expand Down Expand Up @@ -198,7 +199,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {

supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv", ext)
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base", ext)
}

typeAllowed := false
Expand All @@ -217,6 +218,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType)
case "xls":
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}
Expand Down
95 changes: 78 additions & 17 deletions shortcuts/drive/drive_import_common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,54 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)

func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
func TestValidateDriveImportSpec(t *testing.T) {
t.Parallel()

err := validateDriveImportSpec(driveImportSpec{
FilePath: "./data.xlsx",
DocType: "docx",
})
if err == nil || !strings.Contains(err.Error(), "file type mismatch") {
t.Fatalf("expected file type mismatch error, got %v", err)
tests := []struct {
name string
spec driveImportSpec
wantErr string
}{
{
name: "xlsx as docx rejected",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "docx"},
wantErr: "file type mismatch",
},
{
name: "xls bitable rejected",
spec: driveImportSpec{FilePath: "./data.xls", DocType: "bitable"},
wantErr: ".xls files can only be imported as 'sheet'",
},
{
name: "base bitable ok",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "bitable"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
wantErr: ".base files can only be imported as 'bitable'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
wantErr: "unsupported file extension",
},
}
}

func TestValidateDriveImportSpecRejectsXlsBitable(t *testing.T) {
t.Parallel()

err := validateDriveImportSpec(driveImportSpec{
FilePath: "./data.xls",
DocType: "bitable",
})
if err == nil || !strings.Contains(err.Error(), ".xls files can only be imported as 'sheet'") {
t.Fatalf("expected xls-only-sheet validation error, got %v", err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveImportSpec(tt.spec)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

Expand Down Expand Up @@ -74,6 +101,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "sheet",
fileSize: driveImport800MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",
docType: "bitable",
fileSize: driveImport20MBFileSizeLimit + 1,
wantText: "exceeds 20.0 MB import limit for .base",
},
{
name: "base within 20mb limit",
filePath: "./snapshot.base",
docType: "bitable",
fileSize: driveImport20MBFileSizeLimit,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -222,6 +262,27 @@ func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
}
}

func TestDriveImportRejectsOversizedBaseFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "too-large.base", driveImport20MBFileSizeLimit+1)

err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "too-large.base",
"--type", "bitable",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected size limit error, got nil")
}
if !strings.Contains(err.Error(), "exceeds 20.0 MB import limit for .base") {
t.Fatalf("unexpected error: %v", err)
}
}

func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
t.Helper()

Expand Down
4 changes: 2 additions & 2 deletions skill-template/domains/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
6. **批量上限 500 条/次** — 同一表建议串行写入,并在批次间延迟 0.5–1 秒
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认
8. **不要走旧 bitable 路径** — Base 场景不要调用 `lark-cli api GET /open-apis/bitable/v1/...`;即使 wiki 解析结果是 `obj_type=bitable`,后续也应继续使用 `lark-cli base ...`
9. **不要把本地文件导入误判成 Base 表内操作** — 如果目标是“把 Excel / CSV 导入成 Base / 多维表格”,必须先走 `lark-cli drive +import --type bitable`;只有导入完成后,才回到 `lark-cli base ...`
9. **不要把本地文件导入误判成 Base 表内操作** — 如果目标是“把 Excel / CSV / `.base` 快照导入成 Base / 多维表格”,必须先走 `lark-cli drive +import --type bitable`;只有导入完成后,才回到 `lark-cli base ...`

## 意图 → 命令索引

Expand All @@ -22,7 +22,7 @@
| 查表字段 | `table.fields list` | 写记录 / 更新前必调 |
| 查记录 | `table.records list` | GET,简单列表,可附带 `view_id` |
| 按视图筛选查询 | `view.filter update` + `table.records list` | 当前 `base/v3` 没有独立 `search` |
| 把本地文件导入为 Base / 多维表格 | `lark-cli drive +import --type bitable` | 导入阶段属于 `drive`,不是 `base` |
| 把本地 Excel / CSV / `.base` 导入为 Base / 多维表格 | `lark-cli drive +import --type bitable` | 导入阶段属于 `drive`,不是 `base` |
| 新增单条记录 | `table.records create` | 少量数据 |
| 更新记录 | `table.records patch` | 只传需要变更的字段 |
| 删除记录 | `table.records delete` | 单条删除 |
Expand Down
4 changes: 2 additions & 2 deletions skill-template/domains/drive.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@

> **导入分流规则:** 如果用户要把本地 Excel / CSV 导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。

## 快速决策

- 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
Expand Down
4 changes: 2 additions & 2 deletions skills/lark-base/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ metadata:
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令;如果输入是 Wiki 链接,可先调用 `lark-cli wiki spaces get_node` 解析真实 token。
3. 定位到命令后,先读该命令对应的 reference,再执行命令。
4. 如果用户要把本地 Excel / CSV 导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。

## 2. 模块与命令导航
Expand Down Expand Up @@ -222,7 +222,7 @@ metadata:
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token` 从 `+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
| 本地 Excel / CSV 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |

### 3.3 表名、字段名与表达式引用

Expand Down
Loading
Loading