feat: add docs media-preview shortcut (#334)

Change-Id: I5db9e52008e175f975838c8a9c03254afa30f52b
This commit is contained in:
wittam-01
2026-04-08 23:52:24 +08:00
committed by GitHub
parent 9fab62bf00
commit daa21731ad
8 changed files with 288 additions and 6 deletions

View 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
},
}

View File

@@ -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"`
}

View File

@@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
DocsFetch,
DocsUpdate,
DocMediaInsert,
DocMediaPreview,
DocMediaDownload,
}
}

View File

@@ -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"}

View File

@@ -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. |

View File

@@ -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-downloaddocs +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) — 认证和全局参数

View File

@@ -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) — 认证和全局参数

View 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) — 认证和全局参数