mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
feat(sheets): add float image shortcuts (#494)
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
325
shortcuts/sheets/sheet_float_image.go
Normal file
325
shortcuts/sheets/sheet_float_image.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/sheet_media_upload.go
Normal file
172
shortcuts/sheets/sheet_media_upload.go
Normal file
@@ -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": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<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_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
|
||||
}
|
||||
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,19 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [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 "<float_image_token>" --output ./image.png`。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -247,6 +260,14 @@ lark-cli sheets <resource> <method> [flags] # 调用 API
|
||||
|
||||
- `find` — 查找单元格
|
||||
|
||||
### spreadsheet.sheet.float_images
|
||||
|
||||
- `create` — 创建浮动图片
|
||||
- `patch` — 更新浮动图片
|
||||
- `get` — 获取浮动图片
|
||||
- `query` — 查询所有浮动图片
|
||||
- `delete` — 删除浮动图片
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -259,4 +280,9 @@ lark-cli sheets <resource> <method> [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` |
|
||||
|
||||
|
||||
@@ -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 "<url>" --file ./image.png
|
||||
# 响应: {"file_token":"boxcnXXXX","file_name":"image.png","size":123456,"spreadsheet_token":"<token>"}
|
||||
|
||||
# 2. 用返回的 file_token 作为 --float-image-token
|
||||
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
|
||||
--float-image-token "boxcnXXXX" --range "<sheetId>!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 "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!A1:A1" --width 200 --height 150
|
||||
|
||||
# 指定自定义 ID 和偏移
|
||||
lark-cli sheets +create-float-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!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 "<float_image_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)
|
||||
@@ -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 "<sheetId>" --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)
|
||||
44
skills/lark-sheets/references/lark-sheets-get-float-image.md
Normal file
44
skills/lark-sheets/references/lark-sheets-get-float-image.md
Normal file
@@ -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 "<sheetId>" --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 "<float_image_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)
|
||||
@@ -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 "<sheetId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--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 "<float_image_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)
|
||||
74
skills/lark-sheets/references/lark-sheets-media-upload.md
Normal file
74
skills/lark-sheets/references/lark-sheets-media-upload.md
Normal file
@@ -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 "<url>" --file ./image.png --jq '.data.file_token')
|
||||
|
||||
# 2. 插入浮动图片
|
||||
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
|
||||
--float-image-token "$TOKEN" --range "<sheetId>!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) — 读取浮动图片元数据
|
||||
@@ -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 "<sheetId>" --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)
|
||||
Reference in New Issue
Block a user