From a84fe91fa12bf3ce22f7a97ea2c85cb9e9578dee Mon Sep 17 00:00:00 2001 From: zhanghuanxu Date: Fri, 5 Jun 2026 18:01:27 +0800 Subject: [PATCH] feat: support PDF imports as slides --- shortcuts/drive/drive_import.go | 2 +- shortcuts/drive/drive_import_common.go | 11 ++-- shortcuts/drive/drive_import_common_test.go | 22 +++++++ .../references/lark-drive-import.md | 10 +-- skills/lark-slides/SKILL.md | 2 +- tests/cli_e2e/drive/coverage.md | 7 +- .../cli_e2e/drive/drive_import_dryrun_test.go | 65 +++++++++++++++++++ 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 tests/cli_e2e/drive/drive_import_dryrun_test.go diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 8c5c9a2c8..a6c1d1d18 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -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, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true}, + {Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true}, {Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", 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)"}, diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index 525051359..1b084dae4 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{ "csv": {"sheet", "bitable"}, "base": {"bitable"}, "pptx": {"slides"}, + "pdf": {"slides"}, } // driveImportSpec contains the user-facing import inputs after normalization. @@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) { switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") { case "docx", "doc": return driveImport600MBFileSizeLimit, true - case "pptx": + case "pptx", "pdf": return driveImport500MBFileSizeLimit, true case "txt", "md", "mark", "markdown", "html", "xls", "base": return driveImport20MBFileSizeLimit, true @@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error func validateDriveImportSpec(spec driveImportSpec) error { ext := spec.FileExtension() if ext == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file") } switch spec.DocType { @@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error { supportedTypes, ok := driveImportExtToDocTypes[ext] if !ok { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file") } typeAllowed := false @@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error { 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) - case "pptx": - hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType) + case "pptx", "pdf": + hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType) default: hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType) } diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go index 3f3093c1f..bca627e7a 100644 --- a/shortcuts/drive/drive_import_common_test.go +++ b/shortcuts/drive/drive_import_common_test.go @@ -39,6 +39,10 @@ func TestValidateDriveImportSpec(t *testing.T) { name: "pptx slides ok", spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"}, }, + { + name: "pdf slides ok", + spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"}, + }, { name: "base non bitable rejected", spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"}, @@ -49,6 +53,11 @@ func TestValidateDriveImportSpec(t *testing.T) { spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"}, wantErr: ".pptx files can only be imported as 'slides'", }, + { + name: "pdf non slides rejected", + spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"}, + wantErr: ".pdf files can only be imported as 'slides'", + }, { name: "unknown extension rejected", spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"}, @@ -136,6 +145,19 @@ func TestValidateDriveImportFileSize(t *testing.T) { docType: "slides", fileSize: driveImport500MBFileSizeLimit, }, + { + name: "pdf exceeds 500mb limit", + filePath: "./deck.pdf", + docType: "slides", + fileSize: driveImport500MBFileSizeLimit + 1, + wantText: "exceeds 500.0 MB import limit for .pdf", + }, + { + name: "pdf within 500mb limit", + filePath: "./deck.pdf", + docType: "slides", + fileSize: driveImport500MBFileSizeLimit, + }, { name: "base exceeds 20mb limit", filePath: "./snapshot.base", diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md index 9b5d35aa0..de4a0a005 100644 --- a/skills/lark-drive/references/lark-drive-import.md +++ b/skills/lark-drive/references/lark-drive-import.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。 +将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。 > [!IMPORTANT] > 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。 @@ -40,8 +40,9 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账" # 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB) lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原" -# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB) +# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB) lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报" +lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报" # 导入到指定文件夹,并指定导入后的文件名 lark-cli drive +import --file ./data.csv --type bitable --folder-token --name "导入数据表" @@ -89,6 +90,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run | `.csv` | `sheet`, `bitable` | CSV 数据文件 | | `.base` | `bitable` | 多维表格快照文件 | | `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 | +| `.pdf` | `slides` | PDF 文档 | > [!IMPORTANT] > 用户口头说的 “Base” / “多维表格” / “bitable”,在命令里统一对应 `--type bitable`。 @@ -98,7 +100,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run > - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable` > - `.xls` 文件**只能**导入为 `sheet` > - `.base` 文件**只能**导入为 `bitable` -> - `.pptx` 文件**只能**导入为 `slides` +> - `.pptx` / `.pdf` 文件**只能**导入为 `slides` > - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet` > [!IMPORTANT] @@ -132,7 +134,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run | `.csv` | `bitable` | 100MB | | `.xls` | `sheet` | 20MB | | `.base` | `bitable` | 20MB | -| `.pptx` | `slides` | 500MB | +| `.pptx`, `.pdf` | `slides` | 500MB | - 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。 - “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。 diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 0a1ca3a94..3c324e4cf 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-slides version: 1.0.0 -description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。" +description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:本地 `.pptx` / `.pdf` 导入为 slides(走 `lark-drive` 的 `drive +import --type slides`)、云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。" metadata: requires: bins: ["lark-cli"] diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 1da102998..094ead72e 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -2,8 +2,8 @@ ## Metrics - Denominator: 31 leaf commands -- Covered: 10 -- Coverage: 32.3% +- Covered: 11 +- Coverage: 35.5% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -15,6 +15,7 @@ - TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`. - TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs. +- TestDriveImportDryRun_PDFToSlides: dry-run coverage for `drive +import`; asserts PDF-to-slides request shape across media upload `extra` and import task body without calling live APIs. - TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. - TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. - Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered. @@ -31,7 +32,7 @@ | ✕ | drive +download | shortcut | | none | no file fixture workflow yet | | ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet | | ✕ | drive +export-download | shortcut | | none | no export-download workflow yet | -| ✕ | drive +import | shortcut | | none | no import workflow yet | +| ✓ | drive +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRun_PDFToSlides | `.pdf` source with `--type slides`; media upload `extra.file_extension=pdf`; import task `file_extension=pdf`, `type=slides`, `file_name` | dry-run only; no live import workflow yet | | ✕ | drive +move | shortcut | | none | no move workflow yet | | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | | ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | diff --git a/tests/cli_e2e/drive/drive_import_dryrun_test.go b/tests/cli_e2e/drive/drive_import_dryrun_test.go new file mode 100644 index 000000000..da4e0827b --- /dev/null +++ b/tests/cli_e2e/drive/drive_import_dryrun_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveImportDryRun_PDFToSlides(t *testing.T) { + setDriveDryRunConfigEnv(t) + + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "deck.pdf"), []byte("%PDF-1.7\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+import", + "--file", "./deck.pdf", + "--type", "slides", + "--name", "pdf-deck", + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: tmpDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/upload_all" { + t.Fatalf("upload url=%q, want upload_all\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.file_name").String(); got != "deck.pdf" { + t.Fatalf("upload file_name=%q, want deck.pdf\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.extra").String(); got != `{"file_extension":"pdf","obj_type":"slides"}` { + t.Fatalf("upload extra=%q, want pdf/slides extra\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/import_tasks" { + t.Fatalf("import url=%q, want import_tasks\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.body.file_extension").String(); got != "pdf" { + t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.body.type").String(); got != "slides" { + t.Fatalf("body.type=%q, want slides\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.body.file_name").String(); got != "pdf-deck" { + t.Fatalf("body.file_name=%q, want pdf-deck\nstdout:\n%s", got, out) + } +}