mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
feat: add docs media-preview shortcut (#334)
Change-Id: I5db9e52008e175f975838c8a9c03254afa30f52b
This commit is contained in:
120
shortcuts/doc/doc_media_preview.go
Normal file
120
shortcuts/doc/doc_media_preview.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var previewMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"text/plain": ".txt",
|
||||
}
|
||||
|
||||
const PreviewType_SOURCE_FILE = "16"
|
||||
|
||||
var DocMediaPreview = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-preview",
|
||||
Description: "Preview document media file (auto-detects extension)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.media:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "media file token", Required: true},
|
||||
{Name: "output", Desc: "local save path", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("token")
|
||||
outputPath := runtime.Str("output")
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:token/preview_download").
|
||||
Desc("Preview document media file").
|
||||
Params(map[string]interface{}{"preview_type": PreviewType_SOURCE_FILE}).
|
||||
Set("token", token).Set("output", outputPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("token")
|
||||
outputPath := runtime.Str("output")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
// Early path validation before API call (final validation after auto-extension below)
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token))
|
||||
|
||||
encodedToken := validate.EncodePathSegment(token)
|
||||
apiPath := fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", encodedToken)
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"preview_type": []string{PreviewType_SOURCE_FILE},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("preview failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath := outputPath
|
||||
currentExt := filepath.Ext(outputPath)
|
||||
if currentExt == "" {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := previewMimeToExt[mimeType]; ok {
|
||||
finalPath = outputPath + ext
|
||||
}
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(finalPath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "io", "cannot create parent directory: %v", err)
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"saved_path": safePath,
|
||||
"size_bytes": sizeBytes,
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -285,12 +285,99 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewDryRunUsesMediaEndpoint(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "docs +media-preview"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("output", "", "")
|
||||
if err := cmd.Flags().Set("token", "tok_preview"); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("output", "./asset"); err != nil {
|
||||
t.Fatalf("set --output: %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocMediaPreview.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if len(dry.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(dry.API))
|
||||
}
|
||||
if dry.API[0].Desc != "Preview document media file" {
|
||||
t.Fatalf("dry-run api desc = %q", dry.API[0].Desc)
|
||||
}
|
||||
if dry.API[0].URL != "/open-apis/drive/v1/medias/tok_preview/preview_download" {
|
||||
t.Fatalf("URL = %q, want media preview endpoint", dry.API[0].URL)
|
||||
}
|
||||
if got, _ := dry.API[0].Params["preview_type"].(string); got != PreviewType_SOURCE_FILE {
|
||||
t.Fatalf("preview_type = %q, want %q", got, PreviewType_SOURCE_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-overwrite-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
|
||||
Status: 200,
|
||||
Body: []byte("new"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("preview.bin", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaPreview, []string{
|
||||
"+media-preview",
|
||||
"--token", "tok_123",
|
||||
"--output", "preview.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
|
||||
Status: 404,
|
||||
Body: "not found",
|
||||
Headers: http.Header{"Content-Type": []string{"text/plain"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaPreview, []string{
|
||||
"+media-preview",
|
||||
"--token", "tok_123",
|
||||
"--output", "preview.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 404") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmpDir, "preview.bin")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("preview target should not be created, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type docDryRunOutput struct {
|
||||
Description string `json:"description"`
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocMediaInsert,
|
||||
DocMediaPreview,
|
||||
DocMediaDownload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,19 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
|
||||
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs media preview shortcut: %v", err)
|
||||
}
|
||||
if previewCmd == nil || previewCmd.Name() != "+media-preview" {
|
||||
t.Fatalf("docs media preview shortcut not mounted: %#v", previewCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
|
||||
|
||||
@@ -120,6 +120,9 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
## 快速决策
|
||||
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
|
||||
- `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
|
||||
@@ -137,6 +140,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. |
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
|
||||
|
||||
## 重要:图片、文件、画板的处理
|
||||
|
||||
**文档中的图片、文件、画板需要通过 `lark-doc-media-download`(docs +media-download)单独获取!**
|
||||
**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取。**
|
||||
|
||||
### 识别格式
|
||||
|
||||
@@ -60,7 +60,11 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
|
||||
### 获取步骤
|
||||
|
||||
1. 从 HTML 标签中提取 `token` 属性值
|
||||
2. 调用 lark-doc-media-download(docs +media-download):
|
||||
2. 如果目标是图片/文件素材,且用户只是想查看/预览,调用 [`lark-doc-media-preview`](lark-doc-media-preview.md)(`docs +media-preview`):
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "提取的token" --output ./preview_media
|
||||
```
|
||||
3. 如果用户明确要下载,或目标是 `<whiteboard token="..."/>`,调用 [`lark-doc-media-download`](lark-doc-media-download.md)(`docs +media-download`):
|
||||
```bash
|
||||
lark-cli docs +media-download --token "提取的token" --output ./downloaded_media
|
||||
```
|
||||
@@ -87,6 +91,7 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
|
||||
| 需求 | 工具 |
|
||||
|------|------|
|
||||
| 获取文档文本 | `docs +fetch` |
|
||||
| 预览图片/文件素材 | `docs +media-preview` |
|
||||
| 下载图片/文件/画板 | `docs +media-download` |
|
||||
| 创建新文档 | `docs +create` |
|
||||
| 更新文档内容 | `docs +update` |
|
||||
@@ -95,5 +100,6 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
|
||||
|
||||
- [lark-doc-create](lark-doc-create.md) — 创建文档
|
||||
- [lark-doc-update](lark-doc-update.md) — 更新文档
|
||||
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
|
||||
下载文档中的图片/文件素材(`file_token`),或下载画板缩略图(`whiteboard_id`)。当 `--output` 不带扩展名时,会根据响应的 `Content-Type` 自动补全扩展名。
|
||||
|
||||
## 选择规则
|
||||
|
||||
- 用户明确说“下载素材”时,使用 `docs +media-download`
|
||||
- 用户只是想查看、预览图片或文件素材时,优先使用 [`docs +media-preview`](lark-doc-media-preview.md)
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,继续使用 `docs +media-download --type whiteboard`;`+media-preview` 不支持画板
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -33,7 +39,12 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
- 画板:`<whiteboard token="..."/>`
|
||||
|
||||
## 排障
|
||||
|
||||
- 如果报错返回的信息包含 `HTTP 403`,且目标是图片/文件素材,可以改成调用 [`docs +media-preview`](lark-doc-media-preview.md) 看是否能先预览内容
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(用于提取 token)
|
||||
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
41
skills/lark-doc/references/lark-doc-media-preview.md
Normal file
41
skills/lark-doc/references/lark-doc-media-preview.md
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
# docs +media-preview(预览文档素材)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
优先用于查看、预览文档中的图片或文件素材(`file_token`)。命令会把素材保存到本地路径,便于后续打开查看内容。
|
||||
|
||||
## 选择规则
|
||||
|
||||
- 用户说“看一下素材 / 图片 / 附件”“预览一下”时,优先使用 `docs +media-preview`
|
||||
- 用户明确说“下载”时,使用 [`docs +media-download`](lark-doc-media-download.md)
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,不要使用 `+media-preview`,改用 `docs +media-download --type whiteboard`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 预览图片/文件素材
|
||||
lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset
|
||||
|
||||
# 指定输出文件名(带扩展名则不会自动补全)
|
||||
lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token <token>` | 是 | 素材 token,即 `file_token` |
|
||||
| `--output <path>` | 是 | 本地保存路径;不带扩展名会自动补全 |
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容(用于提取 token)
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 明确下载素材,或下载画板缩略图
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
Reference in New Issue
Block a user