From ff6ee98ca58a2bf2fc72035a3d1592a3037c70c7 Mon Sep 17 00:00:00 2001 From: / Date: Wed, 15 Apr 2026 18:48:44 +0800 Subject: [PATCH] feat(sheets): add float image shortcuts Implement +create-float-image, +update-float-image, +get-float-image, +list-float-images, and +delete-float-image shortcuts wrapping the v3 spreadsheet float_image API. The create reference doc includes the prerequisite media upload step with the correct parent_type (sheet_image) to avoid common token mismatch errors. --- internal/cmdutil/fileupload.go | 16 +- internal/cmdutil/fileupload_test.go | 37 ++ internal/output/lark_errors.go | 9 + internal/output/lark_errors_test.go | 7 + shortcuts/sheets/sheet_float_image.go | 325 +++++++++++ shortcuts/sheets/sheet_float_image_test.go | 524 ++++++++++++++++++ shortcuts/sheets/sheet_media_upload.go | 172 ++++++ shortcuts/sheets/sheet_media_upload_test.go | 237 ++++++++ shortcuts/sheets/shortcuts.go | 6 + skills/lark-sheets/SKILL.md | 26 + .../lark-sheets-create-float-image.md | 86 +++ .../lark-sheets-delete-float-image.md | 37 ++ .../references/lark-sheets-get-float-image.md | 44 ++ .../lark-sheets-list-float-images.md | 43 ++ .../references/lark-sheets-media-upload.md | 74 +++ .../lark-sheets-update-float-image.md | 52 ++ 16 files changed, 1694 insertions(+), 1 deletion(-) create mode 100644 shortcuts/sheets/sheet_float_image.go create mode 100644 shortcuts/sheets/sheet_float_image_test.go create mode 100644 shortcuts/sheets/sheet_media_upload.go create mode 100644 shortcuts/sheets/sheet_media_upload_test.go create mode 100644 skills/lark-sheets/references/lark-sheets-create-float-image.md create mode 100644 skills/lark-sheets/references/lark-sheets-delete-float-image.md create mode 100644 skills/lark-sheets/references/lark-sheets-get-float-image.md create mode 100644 skills/lark-sheets/references/lark-sheets-list-float-images.md create mode 100644 skills/lark-sheets/references/lark-sheets-media-upload.md create mode 100644 skills/lark-sheets/references/lark-sheets-update-float-image.md diff --git a/internal/cmdutil/fileupload.go b/internal/cmdutil/fileupload.go index 541c1fde1..15a99980d 100644 --- a/internal/cmdutil/fileupload.go +++ b/internal/cmdutil/fileupload.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "io" + "strconv" "strings" "github.com/larksuite/cli/extension/fileio" @@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo // Add top-level JSON keys as text form fields. if m, ok := dataJSON.(map[string]any); ok { for k, v := range m { - fd.AddField(k, fmt.Sprintf("%v", v)) + fd.AddField(k, formatFormFieldValue(v)) } } return fd, nil } + +// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form +// field string. float64 is handled specially: fmt's default %v/%g switches to +// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some +// backends reject when parsing the field as an integer. Use decimal notation +// instead so size / block_num / offset-style numeric fields round-trip cleanly. +// All other types fall through to %v. +func formatFormFieldValue(v any) string { + if n, ok := v.(float64); ok { + return strconv.FormatFloat(n, 'f', -1, 64) + } + return fmt.Sprintf("%v", v) +} diff --git a/internal/cmdutil/fileupload_test.go b/internal/cmdutil/fileupload_test.go index abd3861c3..4e0ab1ca3 100644 --- a/internal/cmdutil/fileupload_test.go +++ b/internal/cmdutil/fileupload_test.go @@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) { } }) } + +// TestFormatFormFieldValue locks in the fix for the float64 -> scientific +// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for +// float64 delegates to %g which switches to scientific notation at ~1e6 +// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an +// integer reject that, surfacing as a generic "params error". +func TestFormatFormFieldValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in any + want string + }{ + {"float64 large integer avoids scientific", float64(1185356), "1185356"}, + {"float64 below scientific threshold", float64(358934), "358934"}, + {"float64 zero", float64(0), "0"}, + {"float64 huge", float64(20 * 1024 * 1024), "20971520"}, + {"float64 negative", float64(-42), "-42"}, + {"float64 fractional preserved", float64(3.14), "3.14"}, + {"string pass-through", "hello", "hello"}, + {"bool true", true, "true"}, + {"int via %v", 42, "42"}, + {"int64 via %v", int64(9007199254740992), "9007199254740992"}, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := formatFormFieldValue(tt.in) + if got != tt.want { + t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index e9c6fceb1..e58d306f8 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -38,6 +38,9 @@ const ( LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support LarkErrDriveCrossBrand = 1064511 // cross brand not support + + // Sheets float image: width/height/offset out of range or invalid. + LarkErrSheetsFloatImageInvalidDims = 1310246 ) // ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint). @@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) { return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit" case LarkErrDriveCrossBrand: return ExitAPI, "cross_brand", "operate on source and target within the same brand environment" + + // sheets-specific constraints that benefit from actionable hints + case LarkErrSheetsFloatImageInvalidDims: + return ExitAPI, "invalid_params", + "check --width / --height / --offset-x / --offset-y: " + + "width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height" } return ExitAPI, "api_error", "" diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index b9ae5569b..8b2fa267d 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { wantType: "cross_brand", wantHint: "same brand environment", }, + { + name: "sheets float image invalid dims", + code: LarkErrSheetsFloatImageInvalidDims, + wantExitCode: ExitAPI, + wantType: "invalid_params", + wantHint: "--width / --height / --offset-x / --offset-y", + }, } for _, tt := range tests { diff --git a/shortcuts/sheets/sheet_float_image.go b/shortcuts/sheets/sheet_float_image.go new file mode 100644 index 000000000..3d96b1e0b --- /dev/null +++ b/shortcuts/sheets/sheet_float_image.go @@ -0,0 +1,325 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func floatImageBasePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func floatImageItemPath(token, sheetID, floatImageID string) string { + return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID)) +} + +func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) { + token := runtime.Str("spreadsheet-token") + if u := runtime.Str("url"); u != "" { + if parsed := extractSpreadsheetToken(u); parsed != u { + token = parsed + } + } + if token == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + return token, nil +} + +func validateFloatImageRange(sheetID, rangeVal string) error { + if rangeVal == "" { + return nil + } + if err := validateSingleCellRange(rangeVal); err != nil { + return err + } + if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID { + return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID) + } + return nil +} + +// validateFloatImageUpdatePayload rejects an update request that carries no +// mutable field. Without this, PATCH {} reaches the server as a confusing +// no-op or opaque error. +func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { + hasField := runtime.Str("range") != "" || + runtime.Cmd.Flags().Changed("width") || + runtime.Cmd.Flags().Changed("height") || + runtime.Cmd.Flags().Changed("offset-x") || + runtime.Cmd.Flags().Changed("offset-y") + if !hasField { + return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update") + } + return nil +} + +// validateFloatImageDims checks the numeric bounds we can verify without +// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0. +// The upper bounds (offset < anchor cell's width/height) are validated by +// the server and surfaced through the 1310246 error hint. +// Only flags explicitly supplied by the user are checked, so omitted flags +// (which fall back to server defaults) pass through unchanged. +func validateFloatImageDims(runtime *common.RuntimeContext) error { + if runtime.Cmd.Flags().Changed("width") { + if v := runtime.Int("width"); v < 20 { + return common.FlagErrorf("--width must be >= 20 pixels, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("height") { + if v := runtime.Int("height"); v < 20 { + return common.FlagErrorf("--height must be >= 20 pixels, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("offset-x") { + if v := runtime.Int("offset-x"); v < 0 { + return common.FlagErrorf("--offset-x must be >= 0, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("offset-y") { + if v := runtime.Int("offset-y"); v < 0 { + return common.FlagErrorf("--offset-y must be >= 0, got %d", v) + } + } + return nil +} + +func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} { + body := map[string]interface{}{} + if includeToken { + if s := runtime.Str("float-image-token"); s != "" { + body["float_image_token"] = s + } + } + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if runtime.Cmd.Flags().Changed("width") { + body["width"] = runtime.Int("width") + } + if runtime.Cmd.Flags().Changed("height") { + body["height"] = runtime.Int("height") + } + if runtime.Cmd.Flags().Changed("offset-x") { + body["offset_x"] = runtime.Int("offset-x") + } + if runtime.Cmd.Flags().Changed("offset-y") { + body["offset_y"] = runtime.Int("offset-y") + } + return body +} + +// SheetCreateFloatImage creates a float image on a sheet. +var SheetCreateFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+create-float-image", + Description: "Create a floating image on a sheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-token", Desc: "image file token (from upload API)", Required: true}, + {Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true}, + {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, + {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, + {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFloatImageToken(runtime); err != nil { + return err + } + if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return validateFloatImageDims(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, true) + if s := runtime.Str("float-image-id"); s != "" { + body["float_image_id"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, true) + if s := runtime.Str("float-image-id"); s != "" { + body["float_image_id"] = s + } + data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetUpdateFloatImage updates a float image's properties. +var SheetUpdateFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+update-float-image", + Description: "Update a floating image", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + {Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"}, + {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, + {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, + {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFloatImageToken(runtime); err != nil { + return err + } + if err := validateFloatImageUpdatePayload(runtime); err != nil { + return err + } + if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return validateFloatImageDims(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, false) + return common.NewDryRunAPI(). + PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, false) + data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetGetFloatImage retrieves a single float image. +var SheetGetFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+get-float-image", + Description: "Get a floating image by ID", + Risk: "read", + Scopes: []string{"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", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetListFloatImages queries all float images in a sheet. +var SheetListFloatImages = common.Shortcut{ + Service: "sheets", + Command: "+list-float-images", + Description: "List all floating images in a sheet", + Risk: "read", + Scopes: []string{"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", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetDeleteFloatImage deletes a float image. +var SheetDeleteFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+delete-float-image", + Description: "Delete a floating image", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_float_image_test.go b/shortcuts/sheets/sheet_float_image_test.go new file mode 100644 index 000000000..e8658d199 --- /dev/null +++ b/shortcuts/sheets/sheet_float_image_test.go @@ -0,0 +1,524 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── CreateFloatImage ──────────────────────────────────────────────────────── + +func TestCreateFloatImageValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestCreateFloatImageValidateSuccess(t *testing.T) { + t.Parallel() + // Pixel flags are int-typed by the shortcut; leave them unset (empty + // intFlags map) so Cmd.Flags().Changed(...) returns false and + // validateFloatImageDims doesn't try to read non-existent ints. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + }, nil, nil) + if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:B2", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "single cell") { + t.Fatalf("expected single-cell error, got: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-token": "boxToken", "range": "other!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected sheet-id mismatch error, got: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + intFlags map[string]int + wantSubst string + }{ + {"width below 20", map[string]int{"width": 5}, "--width must be >= 20"}, + {"height below 20", map[string]int{"height": 10}, "--height must be >= 20"}, + {"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"}, + {"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"}, + } + + baseStr := map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) { + t.Parallel() + // Boundary values exactly at the lower bound should pass. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + }, + map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil) + if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("boundary values should pass, got: %v", err) + } +} + +func TestCreateFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "", + }, + map[string]int{"width": 200, "height": 150}, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"POST"`) { + t.Fatalf("DryRun should use POST: %s", got) + } + if !strings.Contains(got, `float_images`) { + t.Fatalf("DryRun URL missing float_images: %s", got) + } + if !strings.Contains(got, `"float_image_token":"boxToken"`) { + t.Fatalf("DryRun missing float_image_token: %s", got) + } + if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) { + t.Fatalf("DryRun should emit numeric width/height, got: %s", got) + } +} + +func TestCreateFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{ + "float_image_id": "fi12345678", "float_image_token": "boxToken", + "range": "sheet1!A1:A1", "width": 200, "height": 150, + }, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--width", "200", "--height", "150", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "float_image_id") { + t.Fatalf("stdout missing float_image_id: %s", stdout.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["float_image_token"] != "boxToken" { + t.Fatalf("unexpected float_image_token: %v", body["float_image_token"]) + } + if w, ok := body["width"].(float64); !ok || w != 200 { + t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"]) + } + if h, ok := body["height"].(float64); !ok || h != 150 { + t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"]) + } +} + +func TestCreateFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi12345678"}, + }}, + }) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFloatImageExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── UpdateFloatImage ──────────────────────────────────────────────────────── + +func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) { + t.Parallel() + // Only IDs set, no mutable field: PATCH would be an empty {} body. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + }, nil, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") { + t.Fatalf("expected empty-payload error, got: %v", err) + } +} + +func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) { + t.Parallel() + // Any single mutable field should satisfy the payload check. + tests := []struct { + name string + strFlags map[string]string + intFlags map[string]int + }{ + { + name: "range only", + strFlags: map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "sheet1!B2:B2", + }, + }, + { + name: "offset-x only (zero value)", + strFlags: map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + }, + intFlags: map[string]int{"offset-x": 0}, + }, + } + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) + if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "other!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", + }, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected sheet-id mismatch error, got: %v", err) + } +} + +func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + intFlags map[string]int + wantSubst string + }{ + {"width below 20", map[string]int{"width": 19}, "--width must be >= 20"}, + {"height below 20", map[string]int{"height": 0}, "--height must be >= 20"}, + {"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"}, + {"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"}, + } + + baseStr := map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestUpdateFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "float-image-id": "fi12345678", "range": "sheet1!B2:B2", + }, + map[string]int{"width": 300, "offset-y": 10}, nil) + got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PATCH"`) { + t.Fatalf("DryRun should use PATCH: %s", got) + } + if !strings.Contains(got, `fi12345678`) { + t.Fatalf("DryRun missing float_image_id: %s", got) + } + if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) { + t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got) + } +} + +func TestUpdateFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ + "+update-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", + "--width", "300", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ + "+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", + "--range", "sheet1!C3:C3", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── GetFloatImage ─────────────────────────────────────────────────────────── + +func TestGetFloatImageValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1", + }, nil) + err := SheetGetFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestGetFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"GET"`) { + t.Fatalf("DryRun should use GET: %s", got) + } + if !strings.Contains(got, `fi123`) { + t.Fatalf("DryRun missing float_image_id: %s", got) + } +} + +func TestGetFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{ + "float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100, + }, + }}, + }) + err := mountAndRunSheets(t, SheetGetFloatImage, []string{ + "+get-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fi123") { + t.Fatalf("stdout missing fi123: %s", stdout.String()) + } +} + +func TestGetFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFloatImage, []string{ + "+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── ListFloatImages ───────────────────────────────────────────────────────── + +func TestListFloatImagesDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt)) + if !strings.Contains(got, `float_images/query`) { + t.Fatalf("DryRun URL missing query: %s", got) + } +} + +func TestListFloatImagesExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"float_image_id": "fi1"}, + map[string]interface{}{"float_image_id": "fi2"}, + }, + }}, + }) + err := mountAndRunSheets(t, SheetListFloatImages, []string{ + "+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fi1") { + t.Fatalf("stdout missing fi1: %s", stdout.String()) + } +} + +func TestListFloatImagesWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, + }) + err := mountAndRunSheets(t, SheetListFloatImages, []string{ + "+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DeleteFloatImage ──────────────────────────────────────────────────────── + +func TestDeleteFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } +} + +func TestDeleteFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ + "+delete-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ + "+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/sheets/sheet_media_upload.go b/shortcuts/sheets/sheet_media_upload.go new file mode 100644 index 000000000..93963d3ef --- /dev/null +++ b/shortcuts/sheets/sheet_media_upload.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// sheetImageParentType is the parent_type accepted by the drive media upload +// endpoint for media that will be anchored via +create-float-image. +const sheetImageParentType = "sheet_image" + +// SheetMediaUpload uploads a local image to the drive media endpoint against +// a spreadsheet and returns the file_token. The token is usable as the +// --float-image-token argument to +create-float-image. +// +// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are +// streamed via upload_prepare / upload_part / upload_finish. This matches the +// pattern used by docs +media-upload and drive +import. +var SheetMediaUpload = common.Shortcut{ + Service: "sheets", + Command: "+media-upload", + Description: "Upload a local image for use as a floating image and return the file_token", + Risk: "write", + Scopes: []string{"docs:document.media:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSheetMediaUploadParent(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + parentNode, err := resolveSheetMediaUploadParent(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + filePath := runtime.Str("file") + fileName := filepath.Base(filePath) + + dry := common.NewDryRunAPI() + if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) { + dry.Desc("chunked media upload (files > 20MB)"). + POST("/open-apis/drive/v1/medias/upload_prepare"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": sheetImageParentType, + "parent_node": parentNode, + "size": "", + }). + POST("/open-apis/drive/v1/medias/upload_part"). + Body(map[string]interface{}{ + "upload_id": "", + "seq": "", + "size": "", + "file": "", + }). + POST("/open-apis/drive/v1/medias/upload_finish"). + Body(map[string]interface{}{ + "upload_id": "", + "block_num": "", + }) + return dry.Set("spreadsheet_token", parentNode) + } + return dry.Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/medias/upload_all"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": sheetImageParentType, + "parent_node": parentNode, + "size": "", + "file": "@" + filePath, + }). + Set("spreadsheet_token", parentNode) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + parentNode, err := resolveSheetMediaUploadParent(runtime) + if err != nil { + return err + } + filePath := runtime.Str("file") + + stat, err := runtime.FileIO().Stat(filePath) + if err != nil { + return common.WrapInputStatError(err, "file not found") + } + if !stat.Mode().IsRegular() { + return output.ErrValidation("file must be a regular file: %s", filePath) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n", + fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode)) + if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") + } + + fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": stat.Size(), + "spreadsheet_token": parentNode, + }, nil) + return nil + }, +} + +// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node, +// accepting either --url or --spreadsheet-token. +func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) { + token := runtime.Str("spreadsheet-token") + if u := runtime.Str("url"); u != "" { + if parsed := extractSpreadsheetToken(u); parsed != "" { + token = parsed + } + } + if token == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + return token, nil +} + +// uploadSheetMediaFile routes to the single-part or multipart upload path based +// on file size. Always uses parent_type=sheet_image so the returned token can +// be consumed by +create-float-image. +func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { + if fileSize <= common.MaxDriveMediaUploadSinglePartSize { + pn := parentNode + return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetImageParentType, + ParentNode: &pn, + }) + } + return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetImageParentType, + ParentNode: parentNode, + }) +} + +// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses +// local stat as a best-effort planning hint. Execute re-validates before +// choosing the actual upload path. +func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool { + info, err := fio.Stat(filePath) + if err != nil { + return false + } + return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize +} diff --git a/shortcuts/sheets/sheet_media_upload_test.go b/shortcuts/sheets/sheet_media_upload_test.go new file mode 100644 index 000000000..e4d0d5146 --- /dev/null +++ b/shortcuts/sheets/sheet_media_upload_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "encoding/json" + "mime" + "mime/multipart" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSheetMediaUploadValidateMissingToken(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", "--file", "img.png", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { + t.Fatalf("dry-run should use upload_all for small file, got: %s", out) + } + if !strings.Contains(out, `"sheet_image"`) { + t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out) + } + if strings.Contains(out, "upload_prepare") { + t.Fatalf("dry-run should not use multipart for small file, got: %s", out) + } +} + +func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "shtFromURL") { + t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String()) + } +} + +func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + // Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk. + largeFile, err := os.Create("big.png") + if err != nil { + t.Fatal(err) + } + if err := largeFile.Truncate(20*1024*1024 + 1); err != nil { + t.Fatal(err) + } + _ = largeFile.Close() + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err = mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "big.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v1/medias/upload_prepare", + "/open-apis/drive/v1/medias/upload_part", + "/open-apis/drive/v1/medias/upload_finish", + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run should include %q for large file, got: %s", want, out) + } + } + if strings.Contains(out, "upload_all") { + t.Fatalf("dry-run should not use upload_all for large file, got: %s", out) + } +} + +func TestSheetMediaUploadExecuteSuccess(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "img.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("parse output: %v", err) + } + data, _ := envelope["data"].(map[string]interface{}) + if data["file_token"] != "boxTOK123" { + t.Fatalf("file_token = %v, want boxTOK123", data["file_token"]) + } + if data["spreadsheet_token"] != "shtSTUB" { + t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"]) + } + + body := decodeSheetsMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != sheetImageParentType { + t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType) + } + if got := body.Fields["parent_node"]; got != "shtSTUB" { + t.Fatalf("parent_node = %q, want shtSTUB", got) + } + if got := body.Fields["file_name"]; got != "img.png" { + t.Fatalf("file_name = %q, want img.png", got) + } + if got := body.Fields["size"]; got != "9" { + t.Fatalf("size = %q, want 9 (len of png-bytes)", got) + } +} + +func TestSheetMediaUploadFileNotFound(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "missing.png", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") { + t.Fatalf("err = %v, want file-not-found error", err) + } +} + +// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with +// t.Parallel — chdir is process-wide. +func withSheetsTestWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) +} + +type capturedSheetsMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + break + } + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(part) + if part.FileName() != "" { + body.Files[part.FormName()] = buf.Bytes() + continue + } + body.Fields[part.FormName()] = buf.String() + } + return body +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index b97df66c6..4f5543dd5 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut { SheetUpdateDropdown, SheetGetDropdown, SheetDeleteDropdown, + SheetMediaUpload, + SheetCreateFloatImage, + SheetUpdateFloatImage, + SheetGetFloatImage, + SheetListFloatImages, + SheetDeleteFloatImage, } } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 0e4809dc7..91c893f61 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -221,6 +221,19 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]` | [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 | | [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 | +### 浮动图片 + +| Shortcut | 说明 | +|----------|------| +| [`+media-upload`](references/lark-sheets-media-upload.md) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) | +| [`+create-float-image`](references/lark-sheets-create-float-image.md) | 创建浮动图片 | +| [`+update-float-image`](references/lark-sheets-update-float-image.md) | 更新浮动图片属性 | +| [`+get-float-image`](references/lark-sheets-get-float-image.md) | 获取浮动图片 | +| [`+list-float-images`](references/lark-sheets-list-float-images.md) | 查询所有浮动图片 | +| [`+delete-float-image`](references/lark-sheets-delete-float-image.md) | 删除浮动图片 | + +> 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "" --output ./image.png`。 + ## API Resources ```bash @@ -247,6 +260,14 @@ lark-cli sheets [flags] # 调用 API - `find` — 查找单元格 +### spreadsheet.sheet.float_images + + - `create` — 创建浮动图片 + - `patch` — 更新浮动图片 + - `get` — 获取浮动图片 + - `query` — 查询所有浮动图片 + - `delete` — 删除浮动图片 + ## 权限表 | 方法 | 所需 scope | @@ -259,4 +280,9 @@ lark-cli sheets [flags] # 调用 API | `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` | | `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` | | `spreadsheet.sheets.find` | `sheets:spreadsheet:read` | +| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` | +| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` | +| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` | +| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` | +| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` | diff --git a/skills/lark-sheets/references/lark-sheets-create-float-image.md b/skills/lark-sheets/references/lark-sheets-create-float-image.md new file mode 100644 index 000000000..ae9597253 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-create-float-image.md @@ -0,0 +1,86 @@ + +# sheets +create-float-image(创建浮动图片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +create-float-image`。 + +在工作表中创建浮动图片。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 前置步骤:获取 float_image_token + +`--float-image-token` 由 [`sheets +media-upload`](lark-sheets-media-upload.md) 产出(内部走 `drive/v1/medias/upload_all`,`>20MB` 自动切到分片上传,详见 [`lark-sheets-media-upload.md`](lark-sheets-media-upload.md)): + +```bash +# 1. 上传图片,自动计算大小、自动分片 +lark-cli sheets +media-upload --url "" --file ./image.png +# 响应: {"file_token":"boxcnXXXX","file_name":"image.png","size":123456,"spreadsheet_token":""} + +# 2. 用返回的 file_token 作为 --float-image-token +lark-cli sheets +create-float-image --url "" --sheet-id "" \ + --float-image-token "boxcnXXXX" --range "!A1:A1" +``` + +> **常见错误**: +> - 用 `drive +upload` 的 token → 报 `Wrong Float Image Token`(走的是不同的上传接口,token 格式不兼容;必须用 `sheets +media-upload`) + +## 命令 + +```bash +lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --float-image-token "boxcnXXXX" \ + --range "!A1:A1" --width 200 --height 150 + +# 指定自定义 ID 和偏移 +lark-cli sheets +create-float-image --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --float-image-token "boxcnXXXX" \ + --range "!B2:B2" --width 300 --height 200 \ + --offset-x 10 --offset-y 20 --float-image-id "myImg12345" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--float-image-token` | 是 | 图片 token(通过上方「前置步骤」的素材上传接口获取,不能用 `drive +upload` 的 token) | +| `--range` | 是 | 锚定单元格,必须是单格(如 `sheetId!A1:A1`)。CLI 会校验前缀必须等于 `--sheet-id` | +| `--width` | 否 | 图片宽度(像素,`>=20`;不传则使用图片原始宽度) | +| `--height` | 否 | 图片高度(像素,`>=20`;不传则使用图片原始高度) | +| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) | +| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) | +| `--float-image-id` | 否 | 自定义 10 位字母数字 ID(不传则自动生成) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含 `float_image`(float_image_id, float_image_token, range, width, height, offset_x, offset_y)。**只返回元数据,不含图片字节**,如需查看图片内容见下方「读取图片内容」。 + +## 读取图片内容 + +本接口及 `+get-float-image` / `+list-float-images` 均只返回 `float_image_token`。要读取图片字节,用该 token 调 `docs +media-preview`: + +```bash +lark-cli docs +media-preview --token "" --output ./image.png +``` + +`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。 + +## 常见错误 + +- `1310246 Wrong Float Image Value`:width/height/offset 参数不合法,CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因: + - `--width` / `--height` 小于 20; + - `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度); + - 传了负值。 + +## 参考 + +- [lark-sheets-update-float-image](lark-sheets-update-float-image.md) +- [lark-sheets-get-float-image](lark-sheets-get-float-image.md) +- [lark-sheets-list-float-images](lark-sheets-list-float-images.md) +- [lark-sheets-delete-float-image](lark-sheets-delete-float-image.md) diff --git a/skills/lark-sheets/references/lark-sheets-delete-float-image.md b/skills/lark-sheets/references/lark-sheets-delete-float-image.md new file mode 100644 index 000000000..a6d0af1b9 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-delete-float-image.md @@ -0,0 +1,37 @@ + +# sheets +delete-float-image(删除浮动图片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +delete-float-image`。 + +删除工作表中的浮动图片。 + +> [!CAUTION] +> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --float-image-id "fi12345678" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--float-image-id` | 是 | 浮动图片 ID | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含 `code`(0=成功)和 `msg`。 + +## 参考 + +- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) +- [lark-sheets-list-float-images](lark-sheets-list-float-images.md) diff --git a/skills/lark-sheets/references/lark-sheets-get-float-image.md b/skills/lark-sheets/references/lark-sheets-get-float-image.md new file mode 100644 index 000000000..9b2619fa3 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-get-float-image.md @@ -0,0 +1,44 @@ + +# sheets +get-float-image(获取浮动图片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +get-float-image`。 + +获取单个浮动图片的详细信息。 + +## 命令 + +```bash +lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --float-image-id "fi12345678" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--float-image-id` | 是 | 浮动图片 ID | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含 `float_image`(float_image_id, float_image_token, range, width, height, offset_x, offset_y)。**只返回元数据,不含图片字节**。 + +## 读取图片内容 + +本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`: + +```bash +lark-cli docs +media-preview --token "" --output ./image.png +``` + +`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。 + +## 参考 + +- [lark-sheets-list-float-images](lark-sheets-list-float-images.md) +- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) diff --git a/skills/lark-sheets/references/lark-sheets-list-float-images.md b/skills/lark-sheets/references/lark-sheets-list-float-images.md new file mode 100644 index 000000000..e9fad4606 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-list-float-images.md @@ -0,0 +1,43 @@ + +# sheets +list-float-images(查询浮动图片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +list-float-images`。 + +查询工作表中的所有浮动图片。 + +## 命令 + +```bash +lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含 `items` 数组,每项为一个 float_image 对象(含 `float_image_token`)。**只返回元数据,不含图片字节**。 + +## 读取图片内容 + +本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`: + +```bash +lark-cli docs +media-preview --token "" --output ./image.png +``` + +`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。 + +## 参考 + +- [lark-sheets-get-float-image](lark-sheets-get-float-image.md) +- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) diff --git a/skills/lark-sheets/references/lark-sheets-media-upload.md b/skills/lark-sheets/references/lark-sheets-media-upload.md new file mode 100644 index 000000000..e530fd4b2 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-media-upload.md @@ -0,0 +1,74 @@ + +# sheets +media-upload(上传浮动图片素材) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +media-upload`。 + +把本地图片上传到指定电子表格的素材空间,返回 `file_token`,该 token 可以作为 [`+create-float-image`](lark-sheets-create-float-image.md) 的 `--float-image-token` 使用。 + +> [!CAUTION] +> 这是**写入操作**(创建素材)—— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 说明 + +- 内部调用 `drive/v1/medias/upload_all`,`parent_type` 锁定为 `sheet_image`,`parent_node` 取自 `--url` / `--spreadsheet-token`。 +- 文件大小通过 `FileIO.Stat` 自动读取,无需手动算(跨平台一致)。 +- `>20MB` 自动切换到分片上传(`upload_prepare` → `upload_part` → `upload_finish`),无需额外参数。 + +## 命令 + +```bash +# 小文件(<=20MB) +lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --file ./image.png + +# 也支持 --spreadsheet-token +lark-cli sheets +media-upload --spreadsheet-token "shtxxxxxxxx" --file ./image.png +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--file` | 是 | 本地图片路径,**必须是相对当前工作目录的相对路径**(见下方「注意事项」);>20MB 自动分片 | +| `--dry-run` | 否 | 仅打印请求计划,不执行 | + +## 输出 + +```json +{ + "file_token": "boxcnXXXX", + "file_name": "image.png", + "size": 358934, + "spreadsheet_token": "shtxxxxxxxx" +} +``` + +## 典型用法:上传 + 插入 + +```bash +# 1. 上传 +TOKEN=$(lark-cli sheets +media-upload --url "" --file ./image.png --jq '.data.file_token') + +# 2. 插入浮动图片 +lark-cli sheets +create-float-image --url "" --sheet-id "" \ + --float-image-token "$TOKEN" --range "!A1:A1" --width 300 --height 200 +``` + +## 注意事项 + +- **`--file` 只接受当前工作目录(CWD)下的相对路径**。CLI 的 `SafeInputPath` 会拒绝绝对路径以及逃出 CWD 的路径(`..` 展开后超出 CWD 也会拒)。 + - ❌ 错误:`--file /Users/alice/Desktop/image.png` + - ❌ 错误:`--file ~/Desktop/image.png`(shell 会展开为绝对路径) + - ✅ 正确:`cp /Users/alice/Desktop/image.png ./image.png && lark-cli sheets +media-upload --file ./image.png ...` + - 典型报错:`unsafe file path: --file must be a relative path within the current directory`。 +- 所需权限:`docs:document.media:upload`(与 docs/slides/base 的媒体上传共用同一 scope)。 +- 返回的 `file_token` **只能**用于浮动图片;走 `drive +upload` 拿到的 token 格式不兼容,会报 `Wrong Float Image Token`。 + +## 参考 + +- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) — 用返回的 token 创建浮动图片 +- [lark-sheets-get-float-image](lark-sheets-get-float-image.md) — 读取浮动图片元数据 diff --git a/skills/lark-sheets/references/lark-sheets-update-float-image.md b/skills/lark-sheets/references/lark-sheets-update-float-image.md new file mode 100644 index 000000000..5cc5714cd --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-update-float-image.md @@ -0,0 +1,52 @@ + +# sheets +update-float-image(更新浮动图片) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +update-float-image`。 + +更新浮动图片的位置、大小和偏移量。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --float-image-id "fi12345678" \ + --width 400 --height 300 --offset-y 20 +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--float-image-id` | 是 | 浮动图片 ID | +| `--range` | 否 | 新锚定单元格,必须是单格(如 `sheetId!B2:B2`)。CLI 会校验前缀必须等于 `--sheet-id` | +| `--width` | 否 | 图片宽度(像素,`>=20`) | +| `--height` | 否 | 图片高度(像素,`>=20`) | +| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) | +| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +> 必须至少传入 `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 其中之一;只传 ID 会被 CLI 拦截,避免 PATCH 空对象导致的无操作或服务端错误。 + +## 输出 + +JSON,包含更新后的 `float_image` 对象。**只返回元数据,不含图片字节**,如需查看图片内容用 `float_image_token` 调 `docs +media-preview`(见 [`lark-sheets-create-float-image.md`](lark-sheets-create-float-image.md) 的「读取图片内容」小节)。 + +## 常见错误 + +- `1310246 Wrong Float Image Value`:width/height/offset 参数不合法,CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因: + - `--width` / `--height` 小于 20; + - `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度); + - 传了负值。 + +## 参考 + +- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) +- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)