From 7fdf656ba8b2ff8027685b28877b45406a8182a9 Mon Sep 17 00:00:00 2001 From: / Date: Wed, 8 Apr 2026 18:30:44 +0800 Subject: [PATCH] feat: add sheets +write-image shortcut Send image bytes as JSON body (not multipart/form-data) to match the v2 values_image API contract. Use CallAPI for consistent response envelope handling. Includes skill reference doc and unit tests for validate, dry-run, and execute paths. --- shortcuts/sheets/helpers.go | 23 + shortcuts/sheets/sheet_write_image.go | 120 ++++ shortcuts/sheets/sheet_write_image_test.go | 511 ++++++++++++++++++ shortcuts/sheets/shortcuts.go | 1 + skills/lark-sheets/SKILL.md | 1 + .../references/lark-sheets-write-image.md | 65 +++ 6 files changed, 721 insertions(+) create mode 100644 shortcuts/sheets/sheet_write_image.go create mode 100644 shortcuts/sheets/sheet_write_image_test.go create mode 100644 skills/lark-sheets/references/lark-sheets-write-image.md diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index ba1ca6420..6e8ba91df 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -109,6 +109,29 @@ func validateSheetRangeInput(sheetID, input string) error { return nil } +// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are +// invalid for single-cell operations like write-image. Empty and single-cell +// values pass through. +func validateSingleCellRange(input string) error { + input = normalizeSheetRangeSeparators(input) + if input == "" { + return nil + } + // Extract the sub-range after the sheet ID prefix, if present. + subRange := input + if _, sr, ok := splitSheetRange(input); ok { + subRange = sr + } + if cellSpanRangePattern.MatchString(subRange) { + parts := strings.SplitN(subRange, ":", 2) + if strings.EqualFold(parts[0], parts[1]) { + return nil + } + return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) + } + return nil +} + func looksLikeRelativeRange(input string) bool { input = normalizeSheetRangeSeparators(input) if input == "" { diff --git a/shortcuts/sheets/sheet_write_image.go b/shortcuts/sheets/sheet_write_image.go new file mode 100644 index 000000000..5f6d4498c --- /dev/null +++ b/shortcuts/sheets/sheet_write_image.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "path/filepath" + + "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 SheetWriteImage = common.Shortcut{ + Service: "sheets", + Command: "+write-image", + Description: "Write an image into a spreadsheet cell", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "range", Desc: "target cell (e.g. A1 or !A1). Start and end cell must be the same", Required: true}, + {Name: "image", Desc: "local image file path (supported formats: PNG, JPEG, JPG, GIF, BMP, JFIF, EXIF, TIFF, BPG, HEIC)", Required: true}, + {Name: "name", Desc: "image file name with extension (defaults to the basename of --image)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if err := validateSingleCellRange(runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + imageName := runtime.Str("name") + if imageName == "" { + imageName = filepath.Base(runtime.Str("image")) + } + return common.NewDryRunAPI(). + Desc("JSON upload with inline image bytes"). + POST("/open-apis/sheets/v2/spreadsheets/:token/values_image"). + Body(map[string]interface{}{ + "range": pointRange, + "image": fmt.Sprintf("", runtime.Str("image")), + "name": imageName, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + // Resolve the target cell range (--range is required). + pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + + // Resolve image file. + imagePath := runtime.Str("image") + safePath, err := validate.SafeInputPath(imagePath) + if err != nil { + return output.ErrValidation("unsafe image path: %s", err) + } + stat, err := vfs.Stat(safePath) + if err != nil { + return output.ErrValidation("image file not found: %s", imagePath) + } + if !stat.Mode().IsRegular() { + return output.ErrValidation("image must be a regular file: %s", imagePath) + } + const maxImageSize int64 = 20 * 1024 * 1024 // 20 MB + if stat.Size() > maxImageSize { + return output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + + imageBytes, err := vfs.ReadFile(safePath) + if err != nil { + return output.ErrValidation("cannot read image file: %s", err) + } + + imageName := runtime.Str("name") + if imageName == "" { + imageName = filepath.Base(imagePath) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange) + + // The sheets v2 values_image API expects a JSON body with the image + // as an inline byte array, not multipart/form-data. + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "range": pointRange, + "image": imageBytes, + "name": imageName, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_write_image_test.go b/shortcuts/sheets/sheet_write_image_test.go new file mode 100644 index 000000000..b7e801943 --- /dev/null +++ b/shortcuts/sheets/sheet_write_image_test.go @@ -0,0 +1,511 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func sheetsTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "sheets-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "sheets"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// ── Validate ───────────────────────────────────────────────────────────────── + +func TestSheetWriteImageValidateRequiresToken(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "image": "./logo.png", + "range": "A1", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsURL(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.larksuite.com/sheets/shtABC123", + "image": "./logo.png", + "range": "sheetId!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "sheetId!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateRejectsRelativeRangeWithoutSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--sheet-id") { + t.Fatalf("expected sheet-id error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "A1", + "sheet-id": "sheet1", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateRejectsMultiCellRange(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "sheet1!A1:B2", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "single cell") { + t.Fatalf("expected single cell error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "sheet1!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DryRun ─────────────────────────────────────────────────────────────────── + +func TestSheetWriteImageDryRun(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "sheet1!B2", + "sheet-id": "", + "image": "./chart.png", + "name": "", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"range":"sheet1!B2:B2"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } + if !strings.Contains(got, `"name":"chart.png"`) { + t.Fatalf("DryRun name not derived from image path: %s", got) + } + // JSON escapes < and > to \u003c and \u003e. + if !strings.Contains(got, `binary: ./chart.png`) { + t.Fatalf("DryRun image field not showing binary placeholder: %s", got) + } + if !strings.Contains(got, `"description":"JSON upload with inline image bytes"`) { + t.Fatalf("DryRun description incorrect: %s", got) + } +} + +func TestSheetWriteImageDryRunCustomName(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "sheet1!A1:A1", + "sheet-id": "", + "image": "./output.png", + "name": "revenue_chart.png", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"name":"revenue_chart.png"`) { + t.Fatalf("DryRun should use custom name: %s", got) + } +} + +func TestSheetWriteImageDryRunUsesURL(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "", + "range": "sheet1!C3", + "sheet-id": "", + "image": "./logo.png", + "name": "", + "url": "https://example.larksuite.com/sheets/shtFromURL", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `shtFromURL`) { + t.Fatalf("DryRun should extract token from URL: %s", got) + } + if !strings.Contains(got, `"range":"sheet1!C3:C3"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } +} + +func TestSheetWriteImageDryRunWithSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "A1", + "sheet-id": "mySheet", + "image": "./img.png", + "name": "", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"range":"mySheet!A1:A1"`) { + t.Fatalf("DryRun should normalize relative range with sheet-id: %s", got) + } +} + +// ── Execute ────────────────────────────────────────────────────────────────── + +func TestSheetWriteImageExecuteSendsJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", + "revision": float64(5), + "updateRange": "sheet1!A1:A1", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a small test image file. + imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes + if err := os.WriteFile("test.png", imgData, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./test.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the request was sent as JSON (not multipart/form-data). + if stub.CapturedHeaders == nil { + t.Fatal("request headers not captured") + } + ct := stub.CapturedHeaders.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + + // Verify the captured body contains the image as base64 in JSON. + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if body["range"] != "sheet1!A1:A1" { + t.Fatalf("body range = %v, want sheet1!A1:A1", body["range"]) + } + if body["name"] != "test.png" { + t.Fatalf("body name = %v, want test.png", body["name"]) + } + if body["image"] == nil { + t.Fatal("body image field is nil") + } + + // Verify output contains expected fields. + if !strings.Contains(stdout.String(), "spreadsheetToken") { + t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String()) + } +} + +func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./nonexistent.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for nonexistent file, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteRejectsDirectory(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a directory where the image path points. + if err := os.Mkdir("not_a_file", 0755); err != nil { + t.Fatalf("Mkdir() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./not_a_file", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for directory, got nil") + } + if !strings.Contains(err.Error(), "regular file") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtFromURL", + "revision": float64(1), + "updateRange": "sheet1!B2:B2", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("pic.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--url", "https://example.larksuite.com/sheets/shtFromURL", + "--range", "sheet1!B2:B2", + "--image", "./pic.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "shtFromURL") { + t.Fatalf("stdout missing token: %s", stdout.String()) + } +} + +func TestSheetWriteImageExecuteCustomName(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", + "revision": float64(2), + "updateRange": "sheet1!A1:A1", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("raw.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./raw.png", + "--name", "custom_chart.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if body["name"] != "custom_chart.png" { + t.Fatalf("body name = %v, want custom_chart.png", body["name"]) + } +} + +func TestSheetWriteImageExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Status: 400, + Body: map[string]interface{}{ + "code": 90001, + "msg": "invalid range", + }, + }) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("bad.png", []byte{0x89, 0x50}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./bad.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +func TestSheetWriteImageExecuteRejectsOversizedFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a sparse file that reports > 20MB without writing actual data. + fh, err := os.Create("huge.png") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(21 * 1024 * 1024); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + fh.Close() + + err = mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./huge.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for oversized file, got nil") + } + if !strings.Contains(err.Error(), "exceeds 20MB limit") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteRejectsAbsolutePath(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("abs.png", []byte{0x89, 0x50}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "/etc/passwd", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for absolute path, got nil") + } + if !strings.Contains(err.Error(), "unsafe image path") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 6d26ff4b5..a86ba7b52 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { SheetInfo, SheetRead, SheetWrite, + SheetWriteImage, SheetAppend, SheetFind, SheetCreate, diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index b7040d7d2..c3655f801 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -150,6 +150,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]` | [`+info`](references/lark-sheets-info.md) | View spreadsheet and sheet information | | [`+read`](references/lark-sheets-read.md) | Read spreadsheet cell values | | [`+write`](references/lark-sheets-write.md) | Write to spreadsheet cells (overwrite mode) | +| [`+write-image`](references/lark-sheets-write-image.md) | Write an image into a spreadsheet cell | | [`+append`](references/lark-sheets-append.md) | Append rows to a spreadsheet | | [`+find`](references/lark-sheets-find.md) | Find cells in a spreadsheet | | [`+create`](references/lark-sheets-create.md) | Create a spreadsheet (optional header row and initial data) | diff --git a/skills/lark-sheets/references/lark-sheets-write-image.md b/skills/lark-sheets/references/lark-sheets-write-image.md new file mode 100644 index 000000000..296c48d7e --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-write-image.md @@ -0,0 +1,65 @@ + +# sheets +write-image(写入图片到单元格) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +write-image`。 + +特性: + +- 将本地图片文件写入到电子表格的指定单元格 +- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC +- `--range` 的起始和结束单元格必须相同(单个单元格),如 `A1` 或 `!B2:B2` +- `--name` 默认取 `--image` 的文件名 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 写入图片到指定单元格 +lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ + --range "!B2:B2" \ + --image "./logo.png" + +# 使用 URL + sheet-id,指定单个单元格 +lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --range "C3" \ + --image "./chart.jpg" + +# 自定义图片名称 +lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:A1" \ + --image "./output.png" --name "revenue_chart.png" + +# 仅预览参数(不发请求) +lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ + --range "!B2:B2" --image "./logo.png" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|----|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--range ` | 是 | 目标单元格:`!A1:A1`、`A1`(需配合 `--sheet-id`) | +| `--sheet-id ` | 否 | 工作表 ID | +| `--image ` | 是 | 本地图片文件的**相对路径**(必须在当前目录下,如 `./logo.png`;不支持绝对路径)| +| `--name ` | 否 | 图片文件名(含扩展名,默认取 `--image` 的文件名) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含: + +- `spreadsheetToken` — 表格 token +- `updateRange` — 图片写入的单元格范围 +- `revision` — 工作表版本号 + +## 参考 + +- [lark-sheets-write](lark-sheets-write.md) — 写入普通单元格数据 +- [lark-sheets-read](lark-sheets-read.md) — 写入前可先 read 验证范围 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数