diff --git a/shortcuts/sheets/lark_sheets_cell_data.go b/shortcuts/sheets/lark_sheets_cell_data.go new file mode 100644 index 000000000..8c654716f --- /dev/null +++ b/shortcuts/sheets/lark_sheets_cell_data.go @@ -0,0 +1,421 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func parseValues2DJSON(raw string) ([][]interface{}, error) { + var rows [][]interface{} + if err := json.Unmarshal([]byte(raw), &rows); err != nil { + return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + } + if rows == nil { + return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + } + return rows, nil +} + +var SheetRead = common.Shortcut{ + Service: "sheets", + Command: "+read", + Description: "Read spreadsheet cell values", + 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: "range", Desc: "read range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + readRange := runtime.Str("range") + if readRange == "" && runtime.Str("sheet-id") != "" { + readRange = runtime.Str("sheet-id") + } + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). + Set("token", token).Set("range", readRange) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + readRange := runtime.Str("range") + if readRange == "" && runtime.Str("sheet-id") != "" { + readRange = runtime.Str("sheet-id") + } + + if readRange == "" { + var err error + readRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) + + params := map[string]interface{}{} + renderOption := runtime.Str("value-render-option") + if renderOption != "" { + params["valueRenderOption"] = renderOption + } + + data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetWrite = common.Shortcut{ + Service: "sheets", + Command: "+write", + Description: "Write to spreadsheet cells (overwrite mode)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "write range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "values", Desc: "2D array JSON", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + + if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + writeRange := runtime.Str("range") + if writeRange == "" && runtime.Str("sheet-id") != "" { + writeRange = runtime.Str("sheet-id") + } + values, _ := parseValues2DJSON(runtime.Str("values")) + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). + Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + values, err := parseValues2DJSON(runtime.Str("values")) + if err != nil { + return err + } + + writeRange := runtime.Str("range") + if writeRange == "" && runtime.Str("sheet-id") != "" { + writeRange = runtime.Str("sheet-id") + } + + if writeRange == "" { + var err error + writeRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + + data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": writeRange, + "values": values, + }, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetAppend = common.Shortcut{ + Service: "sheets", + Command: "+append", + Description: "Append rows to a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "append range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "values", Desc: "2D array JSON", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + + if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + appendRange := runtime.Str("range") + if appendRange == "" && runtime.Str("sheet-id") != "" { + appendRange = runtime.Str("sheet-id") + } + values, _ := parseValues2DJSON(runtime.Str("values")) + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). + Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + values, err := parseValues2DJSON(runtime.Str("values")) + if err != nil { + return err + } + + appendRange := runtime.Str("range") + if appendRange == "" && runtime.Str("sheet-id") != "" { + appendRange = runtime.Str("sheet-id") + } + + if appendRange == "" { + var err error + appendRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) + + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": appendRange, + "values": values, + }, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetFind = common.Shortcut{ + Service: "sheets", + Command: "+find", + Description: "Find cells in a spreadsheet", + 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: "find", Desc: "search text", Required: true}, + {Name: "range", Desc: "search range (!A1:D10, or A1:D10 / C2 with --sheet-id)"}, + {Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"}, + {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"}, + {Name: "search-by-regex", Type: "bool", Desc: "regex search"}, + {Name: "include-formulas", Type: "bool", Desc: "search formulas"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": !runtime.Bool("ignore-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find"). + Body(map[string]interface{}{ + "find": runtime.Str("find"), + "find_condition": findCondition, + }). + Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + sheetID := runtime.Str("sheet-id") + findText := runtime.Str("find") + + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": !runtime.Bool("ignore-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) + } + + reqData := map[string]interface{}{ + "find_condition": findCondition, + "find": findText, + } + + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetReplace = common.Shortcut{ + Service: "sheets", + Command: "+replace", + Description: "Find and replace cell values in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "find", Desc: "search text or regex pattern", Required: true}, + {Name: "replacement", Desc: "replacement text", Required: true}, + {Name: "range", Desc: "search range (!A1:D10, or A1:D10 with --sheet-id)"}, + {Name: "match-case", Type: "bool", Desc: "case-sensitive search"}, + {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"}, + {Name: "search-by-regex", Type: "bool", Desc: "use regex search"}, + {Name: "include-formulas", Type: "bool", Desc: "search in formulas"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": runtime.Bool("match-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace"). + Body(map[string]interface{}{ + "find_condition": findCondition, + "find": runtime.Str("find"), + "replacement": runtime.Str("replacement"), + }). + Set("token", token).Set("sheet_id", sheetID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": runtime.Bool("match-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace", + validate.EncodePathSegment(token), + validate.EncodePathSegment(sheetID), + ), + nil, + map[string]interface{}{ + "find_condition": findCondition, + "find": runtime.Str("find"), + "replacement": runtime.Str("replacement"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_write_image.go b/shortcuts/sheets/lark_sheets_cell_images.go similarity index 80% rename from shortcuts/sheets/sheet_write_image.go rename to shortcuts/sheets/lark_sheets_cell_images.go index 5f6d4498c..c84d32496 100644 --- a/shortcuts/sheets/sheet_write_image.go +++ b/shortcuts/sheets/lark_sheets_cell_images.go @@ -6,6 +6,7 @@ package sheets import ( "context" "fmt" + "io/fs" "path/filepath" "github.com/larksuite/cli/internal/output" @@ -43,6 +44,10 @@ var SheetWriteImage = common.Shortcut{ if err := validateSingleCellRange(runtime.Str("range")); err != nil { return err } + _, _, err := validateSheetWriteImageFile(runtime.Str("image")) + if err != nil { + return err + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -71,25 +76,12 @@ var SheetWriteImage = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } - // Resolve the target cell range (--range is required). pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - // Resolve image file. imagePath := runtime.Str("image") - safePath, err := validate.SafeInputPath(imagePath) - if err != nil { - return output.ErrValidation("unsafe image path: %s", err) - } - stat, err := vfs.Stat(safePath) + safePath, stat, err := validateSheetWriteImageFile(imagePath) if err != nil { - return output.ErrValidation("image file not found: %s", imagePath) - } - if !stat.Mode().IsRegular() { - return output.ErrValidation("image must be a regular file: %s", imagePath) - } - const maxImageSize int64 = 20 * 1024 * 1024 // 20 MB - if stat.Size() > maxImageSize { - return output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + return err } imageBytes, err := vfs.ReadFile(safePath) @@ -104,8 +96,6 @@ var SheetWriteImage = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange) - // The sheets v2 values_image API expects a JSON body with the image - // as an inline byte array, not multipart/form-data. data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "range": pointRange, "image": imageBytes, @@ -118,3 +108,22 @@ var SheetWriteImage = common.Shortcut{ return nil }, } + +func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) { + safePath, err := validate.SafeInputPath(imagePath) + if err != nil { + return "", nil, output.ErrValidation("unsafe image path: %s", err) + } + stat, err := vfs.Stat(safePath) + if err != nil { + return "", nil, output.ErrValidation("image file not found: %s", imagePath) + } + if !stat.Mode().IsRegular() { + return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath) + } + const maxImageSize int64 = 20 * 1024 * 1024 + if stat.Size() > maxImageSize { + return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + return safePath, stat, nil +} diff --git a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/lark_sheets_cell_style_and_merge.go new file mode 100644 index 000000000..74679787a --- /dev/null +++ b/shortcuts/sheets/lark_sheets_cell_style_and_merge.go @@ -0,0 +1,350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func validateBatchStyleData(raw string) error { + var data interface{} + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return common.FlagErrorf("--data must be valid JSON: %v", err) + } + arr, ok := data.([]interface{}) + if !ok || len(arr) == 0 { + return common.FlagErrorf("--data must be a non-empty JSON array") + } + for i, item := range arr { + entry, ok := item.(map[string]interface{}) + if !ok { + return common.FlagErrorf("--data[%d] must be an object with ranges and style", i) + } + rangesRaw, ok := entry["ranges"] + if !ok { + return common.FlagErrorf("--data[%d].ranges is required", i) + } + ranges, ok := rangesRaw.([]interface{}) + if !ok || len(ranges) == 0 { + return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i) + } + for j, r := range ranges { + s, ok := r.(string) + if !ok || s == "" { + return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j) + } + if _, _, ok := splitSheetRange(s); !ok { + return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s) + } + } + styleRaw, ok := entry["style"] + if !ok { + return common.FlagErrorf("--data[%d].style is required", i) + } + if _, ok := styleRaw.(map[string]interface{}); !ok { + return common.FlagErrorf("--data[%d].style must be a JSON object", i) + } + } + return nil +} + +var SheetSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+set-style", + Description: "Set cell style for a range", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + {Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + var style interface{} + if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { + return common.FlagErrorf("--style must be valid JSON: %v", err) + } + if _, ok := style.(map[string]interface{}); !ok { + return common.FlagErrorf("--style must be a JSON object, got %T", style) + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + var style interface{} + json.Unmarshal([]byte(runtime.Str("style")), &style) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/style"). + Body(map[string]interface{}{ + "appendStyle": map[string]interface{}{ + "range": r, + "style": style, + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + var style interface{} + if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { + return common.FlagErrorf("--style must be valid JSON: %v", err) + } + + data, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "appendStyle": map[string]interface{}{ + "range": r, + "style": style, + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetBatchSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+batch-set-style", + Description: "Batch set cell styles for multiple ranges", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + return validateBatchStyleData(runtime.Str("data")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + var data interface{} + json.Unmarshal([]byte(runtime.Str("data")), &data) + normalizeBatchStyleRanges(data) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). + Body(map[string]interface{}{ + "data": data, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + var data interface{} + if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { + return common.FlagErrorf("--data must be valid JSON: %v", err) + } + normalizeBatchStyleRanges(data) + + result, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "data": data, + }, + ) + if err != nil { + return err + } + runtime.Out(result, nil) + return nil + }, +} + +func normalizeBatchStyleRanges(data interface{}) { + items, ok := data.([]interface{}) + if !ok { + return + } + for _, item := range items { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + ranges, ok := entry["ranges"].([]interface{}) + if !ok { + continue + } + for i, r := range ranges { + if s, ok := r.(string); ok { + ranges[i] = normalizePointRange("", s) + } + } + } +} + +var SheetMergeCells = common.Shortcut{ + Service: "sheets", + Command: "+merge-cells", + Description: "Merge cells in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + {Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells"). + Body(map[string]interface{}{ + "range": r, + "mergeType": runtime.Str("merge-type"), + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "range": r, + "mergeType": runtime.Str("merge-type"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUnmergeCells = common.Shortcut{ + Service: "sheets", + Command: "+unmerge-cells", + Description: "Unmerge (split) cells in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells"). + Body(map[string]interface{}{ + "range": r, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "range": r, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_dropdown.go b/shortcuts/sheets/lark_sheets_dropdown.go similarity index 100% rename from shortcuts/sheets/sheet_dropdown.go rename to shortcuts/sheets/lark_sheets_dropdown.go diff --git a/shortcuts/sheets/sheet_filter_view_condition.go b/shortcuts/sheets/lark_sheets_filter_views.go similarity index 52% rename from shortcuts/sheets/sheet_filter_view_condition.go rename to shortcuts/sheets/lark_sheets_filter_views.go index 043809ee8..072d3d0ca 100644 --- a/shortcuts/sheets/sheet_filter_view_condition.go +++ b/shortcuts/sheets/lark_sheets_filter_views.go @@ -7,11 +7,21 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) +func filterViewBasePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func filterViewItemPath(token, sheetID, filterViewID string) string { + return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID)) +} + func filterViewConditionBasePath(token, sheetID, filterViewID string) string { return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID)) } @@ -20,6 +30,226 @@ func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID strin return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID)) } +func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) { + return validateSheetManageToken(runtime) +} + +func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool { + return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != "" +} + +var SheetCreateFilterView = common.Shortcut{ + Service: "sheets", + Command: "+create-filter-view", + Description: "Create a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true}, + {Name: "filter-view-name", Desc: "display name (max 100 chars)"}, + {Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range must not be empty") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{"range": runtime.Str("range")} + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + if s := runtime.Str("filter-view-id"); s != "" { + body["filter_view_id"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{"range": runtime.Str("range")} + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + if s := runtime.Str("filter-view-id"); s != "" { + body["filter_view_id"] = s + } + data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateFilterView = common.Shortcut{ + Service: "sheets", + Command: "+update-filter-view", + Description: "Update a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "range", Desc: "new filter range"}, + {Name: "filter-view-name", Desc: "new display name (max 100 chars)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + if !hasNonEmptyStringFlag(runtime, "range") && + !hasNonEmptyStringFlag(runtime, "filter-view-name") { + return common.FlagErrorf("specify at least one of --range or --filter-view-name") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{} + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + return common.NewDryRunAPI(). + PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{} + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetListFilterViews = common.Shortcut{ + Service: "sheets", + Command: "+list-filter-views", + Description: "List all filter views in a sheet", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetGetFilterView = common.Shortcut{ + Service: "sheets", + Command: "+get-filter-view", + Description: "Get a filter view by ID", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteFilterView = common.Shortcut{ + Service: "sheets", + Command: "+delete-filter-view", + Description: "Delete a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + var SheetCreateFilterViewCondition = common.Shortcut{ Service: "sheets", Command: "+create-filter-view-condition", @@ -83,9 +313,9 @@ var SheetUpdateFilterViewCondition = common.Shortcut{ if _, err := validateFilterViewToken(runtime); err != nil { return err } - if !runtime.Cmd.Flags().Changed("filter-type") && - !runtime.Cmd.Flags().Changed("compare-type") && - !runtime.Cmd.Flags().Changed("expected") { + if !hasNonEmptyStringFlag(runtime, "filter-type") && + !hasNonEmptyStringFlag(runtime, "compare-type") && + !hasNonEmptyStringFlag(runtime, "expected") { return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected") } if s := runtime.Str("expected"); s != "" { @@ -227,7 +457,6 @@ var SheetDeleteFilterViewCondition = common.Shortcut{ }, } -// validateExpectedFlag checks that --expected is a valid JSON array. func validateExpectedFlag(s string) error { if s == "" { return nil @@ -239,7 +468,6 @@ func validateExpectedFlag(s string) error { return nil } -// buildConditionBody constructs the request body for condition create/update. func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} { body := map[string]interface{}{} if includeConditionID { @@ -253,7 +481,6 @@ func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) } if s := runtime.Str("expected"); s != "" { var arr []interface{} - // Validate already ensures this is a valid JSON array. _ = json.Unmarshal([]byte(s), &arr) body["expected"] = arr } diff --git a/shortcuts/sheets/sheet_float_image.go b/shortcuts/sheets/lark_sheets_float_images.go similarity index 69% rename from shortcuts/sheets/sheet_float_image.go rename to shortcuts/sheets/lark_sheets_float_images.go index 3d96b1e0b..cb70d6ca0 100644 --- a/shortcuts/sheets/sheet_float_image.go +++ b/shortcuts/sheets/lark_sheets_float_images.go @@ -6,11 +6,164 @@ package sheets import ( "context" "fmt" + "path/filepath" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) +const sheetImageParentType = "sheet_image" + +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 + } + _, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file")) + return err + }, + 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") + + safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath) + if err != nil { + return err + } + + fileName := filepath.Base(safePath) + 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, safePath, 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 + }, +} + +func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) { + stat, err := runtime.FileIO().Stat(filePath) + if err != nil { + return "", nil, common.WrapInputStatError(err, "file not found") + } + if !stat.Mode().IsRegular() { + return "", nil, output.ErrValidation("file must be a regular file: %s", filePath) + } + return filePath, stat, nil +} + +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 +} + +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, + }) +} + +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 +} + 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)) @@ -46,9 +199,6 @@ func validateFloatImageRange(sheetID, rangeVal string) error { 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") || @@ -61,12 +211,6 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { 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 { @@ -116,7 +260,6 @@ func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[ return body } -// SheetCreateFloatImage creates a float image on a sheet. var SheetCreateFloatImage = common.Shortcut{ Service: "sheets", Command: "+create-float-image", @@ -170,7 +313,6 @@ var SheetCreateFloatImage = common.Shortcut{ }, } -// SheetUpdateFloatImage updates a float image's properties. var SheetUpdateFloatImage = common.Shortcut{ Service: "sheets", Command: "+update-float-image", @@ -220,7 +362,6 @@ var SheetUpdateFloatImage = common.Shortcut{ }, } -// SheetGetFloatImage retrieves a single float image. var SheetGetFloatImage = common.Shortcut{ Service: "sheets", Command: "+get-float-image", @@ -255,7 +396,6 @@ var SheetGetFloatImage = common.Shortcut{ }, } -// SheetListFloatImages queries all float images in a sheet. var SheetListFloatImages = common.Shortcut{ Service: "sheets", Command: "+list-float-images", @@ -289,7 +429,6 @@ var SheetListFloatImages = common.Shortcut{ }, } -// SheetDeleteFloatImage deletes a float image. var SheetDeleteFloatImage = common.Shortcut{ Service: "sheets", Command: "+delete-float-image", diff --git a/shortcuts/sheets/lark_sheets_row_column_management.go b/shortcuts/sheets/lark_sheets_row_column_management.go new file mode 100644 index 000000000..5d5f9dec5 --- /dev/null +++ b/shortcuts/sheets/lark_sheets_row_column_management.go @@ -0,0 +1,369 @@ +// 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" +) + +var SheetAddDimension = common.Shortcut{ + Service: "sheets", + Command: "+add-dimension", + Description: "Add rows or columns at the end of a sheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + length := runtime.Int("length") + if length < 1 || length > 5000 { + return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetInsertDimension = common.Shortcut{ + Service: "sheets", + Command: "+insert-dimension", + Description: "Insert rows or columns at a specified position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true}, + {Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") <= runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be greater than --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range"). + Body(body). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), + nil, body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateDimension = common.Shortcut{ + Service: "sheets", + Command: "+update-dimension", + Description: "Update row or column properties (visibility, size)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + {Name: "visible", Type: "bool", Desc: "true to show, false to hide"}, + {Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { + return common.FlagErrorf("specify at least one of --visible or --fixed-size") + } + if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { + return common.FlagErrorf("--fixed-size must be >= 1") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + + data, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetMoveDimension = common.Shortcut{ + Service: "sheets", + Command: "+move-dimension", + Description: "Move rows or columns to a new position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true}, + {Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if runtime.Int("destination-index") < 0 { + return common.FlagErrorf("--destination-index must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension"). + Body(map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }). + Set("token", token). + Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", + validate.EncodePathSegment(token), + validate.EncodePathSegment(runtime.Str("sheet-id")), + ), + nil, + map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteDimension = common.Shortcut{ + Service: "sheets", + Command: "+delete-dimension", + Description: "Delete rows or columns", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("DELETE", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_cell_ops_test.go b/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go similarity index 94% rename from shortcuts/sheets/sheet_cell_ops_test.go rename to shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go index a1316bf86..708fc9f1a 100644 --- a/shortcuts/sheets/sheet_cell_ops_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go @@ -527,6 +527,65 @@ func TestSheetBatchSetStyleValidateSuccess(t *testing.T) { } } +func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantSubst string + }{ + { + name: "entry must be object", + data: `["bad"]`, + wantSubst: "must be an object with ranges and style", + }, + { + name: "ranges required", + data: `[{"style":{}}]`, + wantSubst: ".ranges is required", + }, + { + name: "ranges must be array", + data: `[{"ranges":"sheet1!A1","style":{}}]`, + wantSubst: ".ranges must be a non-empty array of strings", + }, + { + name: "ranges must not be empty", + data: `[{"ranges":[],"style":{}}]`, + wantSubst: ".ranges must be a non-empty array of strings", + }, + { + name: "range must include sheet prefix", + data: `[{"ranges":["A1"],"style":{}}]`, + wantSubst: "must include a sheetId! prefix", + }, + { + name: "style required", + data: `[{"ranges":["sheet1!A1:B2"]}]`, + wantSubst: ".style is required", + }, + { + name: "style must be object", + data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`, + wantSubst: ".style must be a JSON object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "data": tt.data, + }, nil) + err := SheetBatchSetStyle.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 TestSheetBatchSetStyleDryRun(t *testing.T) { t.Parallel() rt := newSheetsTestRuntime(t, map[string]string{ diff --git a/shortcuts/sheets/sheet_create_test.go b/shortcuts/sheets/lark_sheets_sheet_create_test.go similarity index 94% rename from shortcuts/sheets/sheet_create_test.go rename to shortcuts/sheets/lark_sheets_sheet_create_test.go index 67791be4f..9863abf21 100644 --- a/shortcuts/sheets/sheet_create_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_create_test.go @@ -5,6 +5,7 @@ package sheets import ( "bytes" + "context" "encoding/json" "strings" "testing" @@ -144,6 +145,23 @@ func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) { } } +func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{ + "title": "项目排期", + "folder-token": "fldcn123", + "headers": "", + "data": "", + }, + nil, nil) + got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"folder_token":"fldcn123"`) { + t.Fatalf("DryRun should include folder_token, got: %s", got) + } +} + func TestSheetCreatePreservesBackendURL(t *testing.T) { t.Parallel() diff --git a/shortcuts/sheets/sheet_dimension_test.go b/shortcuts/sheets/lark_sheets_sheet_dimension_test.go similarity index 93% rename from shortcuts/sheets/sheet_dimension_test.go rename to shortcuts/sheets/lark_sheets_sheet_dimension_test.go index bd5d6afed..ee165412a 100644 --- a/shortcuts/sheets/sheet_dimension_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_dimension_test.go @@ -106,6 +106,62 @@ func TestSheetAddDimensionValidateWithURL(t *testing.T) { } } +func TestDimensionShortcutsValidateRejectURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + intFlags map[string]int + boolFlags map[string]bool + }{ + { + name: "add", + shortcut: SheetAddDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"length": 1}, + }, + { + name: "insert", + shortcut: SheetInsertDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": ""}, + intFlags: map[string]int{"start-index": 0, "end-index": 1}, + }, + { + name: "update", + shortcut: SheetUpdateDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 1, "end-index": 1}, + boolFlags: map[string]bool{"visible": true}, + }, + { + name: "move", + shortcut: SheetMoveDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 0, "end-index": 0, "destination-index": 1}, + }, + { + name: "delete", + shortcut: SheetDeleteDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 1, "end-index": 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, tt.boolFlags) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + func TestSheetAddDimensionDryRun(t *testing.T) { t.Parallel() rt := newDimTestRuntime(t, diff --git a/shortcuts/sheets/sheet_dropdown_test.go b/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go similarity index 100% rename from shortcuts/sheets/sheet_dropdown_test.go rename to shortcuts/sheets/lark_sheets_sheet_dropdown_test.go diff --git a/shortcuts/sheets/lark_sheets_sheet_export_test.go b/shortcuts/sheets/lark_sheets_sheet_export_test.go new file mode 100644 index 000000000..83bef63de --- /dev/null +++ b/shortcuts/sheets/lark_sheets_sheet_export_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/tidwall/gjson" +) + +func TestSheetExportValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "file-extension": "xlsx", + "output-path": "", + "sheet-id": "", + }, nil, nil) + err := SheetExport.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } +} + +func TestSheetExportValidateRequiresSheetIDForCSV(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "", + }, nil, nil) + err := SheetExport.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--sheet-id is required when --file-extension is csv") { + t.Fatalf("expected csv sheet-id validation error, got: %v", err) + } +} + +func TestSheetExportValidateAllowsCSVWithSheetID(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "sheet1", + }, nil, nil) + if err := SheetExport.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "sheet1", + }, nil, nil) + got := mustMarshalSheetsDryRun(t, SheetExport.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"sub_id":"sheet1"`) { + t.Fatalf("DryRun should include sub_id for csv export, got: %s", got) + } +} + +func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) { + t.Parallel() + + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetExport, []string{ + "+export", + "--spreadsheet-token", "shtTOKEN", + "--file-extension", "pdf", + "--as", "user", + }, f, nil) + if err == nil || !strings.Contains(err.Error(), `allowed: xlsx, csv`) { + t.Fatalf("expected invalid file-extension error, got: %v", err) + } +} + +func TestSheetExportExecuteWithoutOutputPathReturnsMetadataOnly(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "ticket": "tk_123", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "file_token": "box_123", + }, + }, + }, + }) + + err := mountAndRunSheets(t, SheetExport, []string{ + "+export", + "--spreadsheet-token", "shtTOKEN", + "--file-extension", "xlsx", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := stdout.String() + if gjson.Get(got, "data.file_token").String() != "box_123" || gjson.Get(got, "data.ticket").String() != "tk_123" { + t.Fatalf("stdout should return export metadata, got: %s", got) + } + if strings.Contains(got, `"saved_path"`) { + t.Fatalf("stdout should not include saved_path when --output-path is omitted: %s", got) + } +} diff --git a/shortcuts/sheets/sheet_filter_view_test.go b/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go similarity index 94% rename from shortcuts/sheets/sheet_filter_view_test.go rename to shortcuts/sheets/lark_sheets_sheet_filter_view_test.go index c03a7d86f..a28aec242 100644 --- a/shortcuts/sheets/sheet_filter_view_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go @@ -27,6 +27,35 @@ func TestCreateFilterViewValidateMissingToken(t *testing.T) { } } +func TestValidateFilterViewTokenRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "s1", + "range": "s1!A1:H14", + "filter-view-name": "", + "filter-view-id": "", + }, nil) + _, err := validateFilterViewToken(rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } +} + +func TestCreateFilterViewValidateRejectsEmptyRange(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "", + "filter-view-name": "", "filter-view-id": "", + }, nil) + err := SheetCreateFilterView.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--range must not be empty") { + t.Fatalf("expected empty range error, got: %v", err) + } +} + func TestCreateFilterViewValidateSuccess(t *testing.T) { t.Parallel() rt := newSheetsTestRuntime(t, map[string]string{ @@ -137,6 +166,22 @@ func TestUpdateFilterViewRejectsNoFields(t *testing.T) { } } +func TestUpdateFilterViewRejectsBlankFieldsOnly(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ + "+update-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--range", "", "--filter-view-name", "", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected validation error when only blank update fields are provided, got nil") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error message: %v", err) + } +} + // ── ListFilterViews ────────────────────────────────────────────────────────── func TestListFilterViewsDryRun(t *testing.T) { diff --git a/shortcuts/sheets/sheet_float_image_test.go b/shortcuts/sheets/lark_sheets_sheet_float_image_test.go similarity index 100% rename from shortcuts/sheets/sheet_float_image_test.go rename to shortcuts/sheets/lark_sheets_sheet_float_image_test.go diff --git a/shortcuts/sheets/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/lark_sheets_sheet_manage_test.go new file mode 100644 index 000000000..bd5d8bb86 --- /dev/null +++ b/shortcuts/sheets/lark_sheets_sheet_manage_test.go @@ -0,0 +1,692 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/tidwall/gjson" +) + +func TestSheetCreateSheetValidateMissingToken(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "title": "Sheet 2"}, + nil, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args map[string]string + }{ + { + name: "create-sheet", + shortcut: SheetCreateSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "title": "Data", + }, + }, + { + name: "copy-sheet", + shortcut: SheetCopySheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Copy", + }, + }, + { + name: "delete-sheet", + shortcut: SheetDeleteSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + }, + }, + { + name: "update-sheet", + shortcut: SheetUpdateSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Renamed", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.args, nil, nil) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + +func TestSheetCreateSheetValidateRejectsInvalidTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + wantSubst string + }{ + {name: "special chars", title: "bad/title", wantSubst: "must not contain"}, + {name: "empty", title: "", wantSubst: "must not be empty"}, + {name: "tab", title: "bad\ttitle", wantSubst: "tabs or line breaks"}, + {name: "newline", title: "bad\ntitle", wantSubst: "tabs or line breaks"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "title": tt.title}, + nil, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("expected title error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestSheetCreateSheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "title": "Data"}, + map[string]int{"index": -1}, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { + t.Fatalf("expected index validation error, got: %v", err) + } +} + +func TestSheetCopySheetValidateRejectsInvalidTitle(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "bad\ttitle"}, + nil, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "tabs or line breaks") { + t.Fatalf("expected title error, got: %v", err) + } +} + +func TestSheetCopySheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "Copy"}, + map[string]int{"index": -1}, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { + t.Fatalf("expected index validation error, got: %v", err) + } +} + +func TestSheetUpdateSheetValidateRejectsEmptyTitle(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": ""}, + nil, nil) + err := SheetUpdateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("expected empty-title error, got: %v", err) + } +} + +func TestSheetCreateSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "title": "Data"}, + map[string]int{"index": 0}, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateSheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"addSheet"`) || !strings.Contains(got, `"title":"Data"`) || !strings.Contains(got, `"index":0`) { + t.Fatalf("DryRun body mismatch: %s", got) + } +} + +func TestSheetCreateSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "addSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_new", + "title": "Data", + "index": 0, + }, + }, + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetCreateSheet, []string{ + "+create-sheet", + "--spreadsheet-token", "shtTOKEN", + "--title", "Data", + "--index", "0", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_new" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + requests, _ := body["requests"].([]interface{}) + if len(requests) != 1 { + t.Fatalf("unexpected body: %#v", body) + } + req0, _ := requests[0].(map[string]interface{}) + addSheet, _ := req0["addSheet"].(map[string]interface{}) + props, _ := addSheet["properties"].(map[string]interface{}) + if props["title"] != "Data" { + t.Fatalf("request title = %#v", props["title"]) + } + if idx, ok := props["index"].(float64); !ok || idx != 0 { + t.Fatalf("request index = %#v", props["index"]) + } +} + +func TestSheetCopySheetValidateMissingSheetID(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": ""}, + nil, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--sheet-id") { + t.Fatalf("expected sheet-id error, got: %v", err) + } +} + +func TestSheetCopySheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "title": "Copy"}, + map[string]int{"index": 2}, nil) + got := mustMarshalSheetsDryRun(t, SheetCopySheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"copySheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) || !strings.Contains(got, `"title":"Copy"`) { + t.Fatalf("DryRun body mismatch: %s", got) + } + if !strings.Contains(got, `"[2] Move copied sheet to requested index"`) || !strings.Contains(got, `\u003ccopied_sheet_id\u003e`) || !strings.Contains(got, `"index":2`) { + t.Fatalf("DryRun should describe follow-up move: %s", got) + } +} + +func TestSheetCopySheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "copySheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "title": "Copy", + "index": 1, + }, + }, + }, + }, + }, + }, + } + moveStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "index": 2, + }, + }, + }, + }, + }, + }, + } + reg.Register(copyStub) + reg.Register(moveStub) + + err := mountAndRunSheets(t, SheetCopySheet, []string{ + "+copy-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Copy", + "--index", "2", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_copy" { + t.Fatalf("stdout missing copied sheet id: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.index").Int() != 2 { + t.Fatalf("stdout missing moved index: %s", stdout.String()) + } + + var copyBody map[string]interface{} + if err := json.Unmarshal(copyStub.CapturedBody, ©Body); err != nil { + t.Fatalf("parse copy body: %v", err) + } + if !strings.Contains(string(copyStub.CapturedBody), `"copySheet"`) { + t.Fatalf("copy request missing copySheet: %s", string(copyStub.CapturedBody)) + } + if !strings.Contains(string(moveStub.CapturedBody), `"updateSheet"`) || !strings.Contains(string(moveStub.CapturedBody), `"index":2`) { + t.Fatalf("move request mismatch: %s", string(moveStub.CapturedBody)) + } +} + +func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "copySheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "title": "Copy", + "index": 1, + }, + }, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Status: 400, + Body: map[string]interface{}{ + "code": 1310211, + "msg": "wrong sheet id", + "error": map[string]interface{}{ + "log_id": "log-move-failed", + }, + }, + }) + + err := mountAndRunSheets(t, SheetCopySheet, []string{ + "+copy-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Copy", + "--index", "2", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected move failure, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err) + } + if exitErr.Detail.Code != 1310211 { + t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code) + } + if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) { + t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") { + t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint) + } + if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") { + t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + } + + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["partial_success"] != true { + t.Fatalf("partial_success = %#v, want true", detail["partial_success"]) + } + if detail["sheet_id"] != "sheet_copy" { + t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy") + } + if detail["requested_index"] != 2 { + t.Fatalf("requested_index = %#v, want 2", detail["requested_index"]) + } + if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" { + t.Fatalf("retry_command = %#v", detail["retry_command"]) + } + if detail["log_id"] != "log-move-failed" { + t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed") + } +} + +func TestSheetDeleteSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, + nil, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteSheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"POST"`) { + t.Fatalf("DryRun should use POST: %s", got) + } + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"deleteSheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) { + t.Fatalf("DryRun body mismatch: %s", got) + } +} + +func TestSheetDeleteSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "deleteSheet": map[string]interface{}{ + "result": true, + "sheetId": "sheet1", + }, + }, + }, + }, + }, + }) + + err := mountAndRunSheets(t, SheetDeleteSheet, []string{ + "+delete-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--yes", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gjson.Get(stdout.String(), "data.deleted").Bool() { + t.Fatalf("stdout missing deleted=true: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } +} + +func TestSheetUpdateSheetValidateRequiresMutation(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, + nil, nil) + err := SheetUpdateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "specify at least one") { + t.Fatalf("expected mutation error, got: %v", err) + } +} + +func TestSheetUpdateSheetValidateRejectsBadProtectionConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strFlags map[string]string + intFlags map[string]int + wantSubst string + }{ + { + name: "lock-info requires lock", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock-info": "private", + }, + wantSubst: "--lock when updating protection settings", + }, + { + name: "user-ids requires user-id-type", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock": "LOCK", + "user-ids": `["ou_1"]`, + }, + wantSubst: "--user-ids requires --user-id-type", + }, + { + name: "negative frozen rows rejected", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", + }, + intFlags: map[string]int{"frozen-row-count": -1}, + wantSubst: "--frozen-row-count must be >= 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) + err := SheetUpdateSheet.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 TestSheetUpdateSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{ + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Hidden Sheet", + "lock": "LOCK", + "lock-info": "private", + "user-ids": `["ou_1"]`, + "user-id-type": "open_id", + }, + map[string]int{ + "index": 3, + "frozen-row-count": 2, + "frozen-col-count": 1, + }, + map[string]bool{"hidden": false}, + ) + got := mustMarshalSheetsDryRun(t, SheetUpdateSheet.DryRun(context.Background(), rt)) + for _, want := range []string{ + `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`, + `"user_id_type":"open_id"`, + `"sheetId":"sheet1"`, + `"title":"Hidden Sheet"`, + `"index":3`, + `"hidden":false`, + `"frozenRowCount":2`, + `"frozenColCount":1`, + `"lock":"LOCK"`, + `"lockInfo":"private"`, + `"userIDs":["ou_1"]`, + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun missing %s: %s", want, got) + } + } +} + +func TestSheetUpdateSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update?user_id_type=open_id", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet1", + "title": "Renamed", + "index": 1, + "hidden": true, + "frozenRowCount": 2, + "frozenColCount": 1, + "protect": map[string]interface{}{ + "lock": "LOCK", + "lockInfo": "private", + "userIDs": []interface{}{"ou_1"}, + }, + }, + }, + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetUpdateSheet, []string{ + "+update-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Renamed", + "--index", "1", + "--hidden=true", + "--frozen-row-count", "2", + "--frozen-col-count", "1", + "--lock", "LOCK", + "--lock-info", "private", + "--user-ids", `["ou_1"]`, + "--user-id-type", "open_id", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.title").String() != "Renamed" { + t.Fatalf("stdout missing title: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.grid_properties.frozen_row_count").Int() != 2 { + t.Fatalf("stdout missing frozen_row_count: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.protect.lock_info").String() != "private" { + t.Fatalf("stdout missing lock_info: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + requests, ok := body["requests"].([]interface{}) + if !ok || len(requests) != 1 { + t.Fatalf("unexpected requests body: %#v", body) + } + req0, _ := requests[0].(map[string]interface{}) + updateSheet, _ := req0["updateSheet"].(map[string]interface{}) + props, _ := updateSheet["properties"].(map[string]interface{}) + if props["sheetId"] != "sheet1" || props["title"] != "Renamed" { + t.Fatalf("unexpected properties: %#v", props) + } +} + +func TestBuildUpdateSheetOutputOmitsBlankTitleWhenTitleNotChanged(t *testing.T) { + t.Parallel() + + out, ok := buildUpdateSheetOutput("shtTOKEN", map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet1", + "title": "", + "hidden": false, + "frozenRowCount": 0, + }, + }, + }, + }, + }, false) + if !ok { + t.Fatal("expected output") + } + sheet, _ := out["sheet"].(map[string]interface{}) + if _, exists := sheet["title"]; exists { + t.Fatalf("blank title should be omitted when title is unchanged: %#v", sheet) + } + if sheet["sheet_id"] != "sheet1" { + t.Fatalf("unexpected sheet output: %#v", sheet) + } +} diff --git a/shortcuts/sheets/lark_sheets_sheet_management.go b/shortcuts/sheets/lark_sheets_sheet_management.go new file mode 100644 index 000000000..67ab6f9d7 --- /dev/null +++ b/shortcuts/sheets/lark_sheets_sheet_management.go @@ -0,0 +1,721 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var sheetProtectLockValues = []string{"LOCK", "UNLOCK"} + +func sheetBatchUpdatePath(token string) string { + return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/sheets_batch_update", validate.EncodePathSegment(token)) +} + +func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) { + if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { + return "", err + } + if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { + if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { + return "", common.FlagErrorf("%v", err) + } + return token, nil + } + + url := strings.TrimSpace(runtime.Str("url")) + if url == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + + token := extractSpreadsheetToken(url) + if token == "" || token == url { + return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + } + if err := validate.RejectControlChars(token, "url"); err != nil { + return "", common.FlagErrorf("%v", err) + } + return token, nil +} + +func validateSheetID(flagName, sheetID string) error { + if strings.TrimSpace(sheetID) == "" { + return common.FlagErrorf("specify --%s", flagName) + } + if err := validate.RejectControlChars(sheetID, flagName); err != nil { + return common.FlagErrorf("%v", err) + } + return nil +} + +func validateSheetTitle(flagName, title string) error { + if title == "" { + return common.FlagErrorf("--%s must not be empty", flagName) + } + if strings.ContainsAny(title, "\t\r\n") { + return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName) + } + if err := validate.RejectControlChars(title, flagName); err != nil { + return common.FlagErrorf("%v", err) + } + if len([]rune(title)) > 100 { + return common.FlagErrorf("--%s must be <= 100 characters", flagName) + } + if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) { + return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName) + } + return nil +} + +func validateNonNegativeInt(flagName string, value int) error { + if value < 0 { + return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value) + } + return nil +} + +func buildSheetCreateProperties(runtime *common.RuntimeContext) map[string]interface{} { + properties := map[string]interface{}{} + if runtime.Changed("title") { + properties["title"] = runtime.Str("title") + } + if runtime.Changed("index") { + properties["index"] = runtime.Int("index") + } + return properties +} + +func buildCreateSheetBody(runtime *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "addSheet": map[string]interface{}{ + "properties": buildSheetCreateProperties(runtime), + }, + }, + }, + } +} + +func buildCopySheetBody(runtime *common.RuntimeContext) map[string]interface{} { + copySheet := map[string]interface{}{ + "source": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + }, + } + if runtime.Changed("title") { + copySheet["destination"] = map[string]interface{}{ + "title": runtime.Str("title"), + } + } + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "copySheet": copySheet, + }, + }, + } +} + +func buildDeleteSheetBody(sheetID string) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "deleteSheet": map[string]interface{}{ + "sheetId": sheetID, + }, + }, + }, + } +} + +func buildMoveCopiedSheetBody(sheetID string, index int) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": sheetID, + "index": index, + }, + }, + }, + }, + } +} + +func normalizeSheetProperties(properties map[string]interface{}, titleChanged bool) map[string]interface{} { + sheet := map[string]interface{}{} + if v, ok := properties["sheetId"]; ok { + sheet["sheet_id"] = v + } + if v, ok := properties["title"]; ok { + if title, ok := v.(string); !ok || title != "" || titleChanged { + sheet["title"] = v + } + } + if v, ok := properties["index"]; ok { + sheet["index"] = v + } + if v, ok := properties["hidden"]; ok { + sheet["hidden"] = v + } + + grid := map[string]interface{}{} + if v, ok := properties["frozenRowCount"]; ok { + grid["frozen_row_count"] = v + } + if v, ok := properties["frozenColCount"]; ok { + grid["frozen_column_count"] = v + } + if len(grid) > 0 { + sheet["grid_properties"] = grid + } + + if protect, ok := properties["protect"].(map[string]interface{}); ok { + outProtect := map[string]interface{}{} + if v, ok := protect["lock"]; ok { + outProtect["lock"] = v + } + if v, ok := protect["lockInfo"]; ok { + outProtect["lock_info"] = v + } + if v, ok := protect["userIDs"]; ok { + outProtect["user_ids"] = v + } + if len(outProtect) > 0 { + sheet["protect"] = outProtect + } + } + return sheet +} + +func firstReply(data map[string]interface{}) (map[string]interface{}, bool) { + replies, ok := data["replies"].([]interface{}) + if !ok || len(replies) == 0 { + return nil, false + } + reply, ok := replies[0].(map[string]interface{}) + if !ok { + return nil, false + } + return reply, true +} + +func buildOperateSheetOutput(token string, data map[string]interface{}, opKey string, titleChanged bool) (map[string]interface{}, bool) { + reply, ok := firstReply(data) + if !ok { + return nil, false + } + op, ok := reply[opKey].(map[string]interface{}) + if !ok { + return nil, false + } + properties, ok := op["properties"].(map[string]interface{}) + if !ok { + return nil, false + } + sheet := normalizeSheetProperties(properties, titleChanged) + out := map[string]interface{}{ + "spreadsheet_token": token, + "sheet": sheet, + } + if sheetID, ok := sheet["sheet_id"].(string); ok && sheetID != "" { + out["sheet_id"] = sheetID + } + return out, true +} + +func buildDeleteSheetOutput(token string, sheetID string, data map[string]interface{}) (map[string]interface{}, bool) { + reply, ok := firstReply(data) + if !ok { + return nil, false + } + del, ok := reply["deleteSheet"].(map[string]interface{}) + if !ok { + return nil, false + } + out := map[string]interface{}{ + "spreadsheet_token": token, + "sheet_id": sheetID, + "deleted": true, + } + if v, ok := del["sheetId"].(string); ok && v != "" { + out["sheet_id"] = v + } + if v, ok := del["result"].(bool); ok { + out["deleted"] = v + } + return out, true +} + +func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interface{} { + if base == nil { + return overlay + } + if overlay == nil { + return base + } + out := map[string]interface{}{} + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + if k == "sheet" { + baseSheet, _ := out["sheet"].(map[string]interface{}) + overlaySheet, _ := v.(map[string]interface{}) + mergedSheet := map[string]interface{}{} + for sk, sv := range baseSheet { + mergedSheet[sk] = sv + } + for sk, sv := range overlaySheet { + mergedSheet[sk] = sv + } + out["sheet"] = mergedSheet + continue + } + out[k] = v + } + return out +} + +func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} { + if len(overlay) == 0 { + return detail + } + if detail == nil { + return overlay + } + if existing, ok := detail.(map[string]interface{}); ok { + merged := map[string]interface{}{} + for k, v := range existing { + merged[k] = v + } + for k, v := range overlay { + merged[k] = v + } + return merged + } + + merged := map[string]interface{}{} + for k, v := range overlay { + merged[k] = v + } + merged["cause_detail"] = detail + return merged +} + +func copySheetMoveRetryCommand(token, sheetID string, index int) string { + return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index) +} + +func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { + if strings.TrimSpace(sheetID) == "" { + return err + } + + retryCommand := copySheetMoveRetryCommand(token, sheetID, index) + msg := fmt.Sprintf("sheet copied successfully as %q, but moving it to index %d failed", sheetID, index) + hint := fmt.Sprintf( + "do not retry +copy-sheet: the new sheet already exists as %s\nretry only the move with: %s", + sheetID, + retryCommand, + ) + detail := map[string]interface{}{ + "partial_success": true, + "failed_step": "move_copied_sheet", + "spreadsheet_token": token, + "sheet_id": sheetID, + "requested_index": index, + "retry_command": retryCommand, + } + + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" { + hint = upstreamHint + "\n" + hint + } + return &output.ExitError{ + Code: exitErr.Code, + Detail: &output.ErrDetail{ + Type: exitErr.Detail.Type, + Code: exitErr.Detail.Code, + Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message), + Hint: hint, + ConsoleURL: exitErr.Detail.ConsoleURL, + Risk: exitErr.Detail.Risk, + Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail), + }, + Err: err, + Raw: exitErr.Raw, + } + } + + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "api_error", + Message: fmt.Sprintf("%s: %v", msg, err), + Hint: hint, + Detail: detail, + }, + Err: err, + } +} + +func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { + if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + if runtime.Changed("frozen-row-count") { + if err := validateNonNegativeInt("frozen-row-count", runtime.Int("frozen-row-count")); err != nil { + return err + } + } + if runtime.Changed("frozen-col-count") { + if err := validateNonNegativeInt("frozen-col-count", runtime.Int("frozen-col-count")); err != nil { + return err + } + } + if runtime.Changed("lock-info") { + if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil { + return common.FlagErrorf("%v", err) + } + } + + hasProtectConfig := runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") + if hasProtectConfig { + lock := runtime.Str("lock") + if !runtime.Changed("lock") { + return common.FlagErrorf("specify --lock when updating protection settings") + } + if runtime.Changed("lock-info") && lock != "LOCK" { + return common.FlagErrorf("--lock-info requires --lock LOCK") + } + if runtime.Changed("user-ids") { + if lock != "LOCK" { + return common.FlagErrorf("--user-ids requires --lock LOCK") + } + if runtime.Str("user-id-type") == "" { + return common.FlagErrorf("--user-ids requires --user-id-type") + } + userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return common.FlagErrorf("--user-ids must not be empty") + } + } + } + + hasUpdate := runtime.Changed("title") || + runtime.Changed("index") || + runtime.Changed("hidden") || + runtime.Changed("frozen-row-count") || + runtime.Changed("frozen-col-count") || + hasProtectConfig + if !hasUpdate { + return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids") + } + + return nil +} + +func buildUpdateSheetBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + properties := map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + } + + if runtime.Changed("title") { + properties["title"] = runtime.Str("title") + } + if runtime.Changed("index") { + properties["index"] = runtime.Int("index") + } + if runtime.Changed("hidden") { + properties["hidden"] = runtime.Bool("hidden") + } + if runtime.Changed("frozen-row-count") { + properties["frozenRowCount"] = runtime.Int("frozen-row-count") + } + if runtime.Changed("frozen-col-count") { + properties["frozenColCount"] = runtime.Int("frozen-col-count") + } + if runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") { + protect := map[string]interface{}{ + "lock": runtime.Str("lock"), + } + if runtime.Changed("lock-info") { + protect["lockInfo"] = runtime.Str("lock-info") + } + if runtime.Changed("user-ids") { + userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) + if err != nil { + return nil, err + } + protect["userIDs"] = userIDs + } + properties["protect"] = protect + } + + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": properties, + }, + }, + }, + }, nil +} + +func buildUpdateSheetOutput(token string, data map[string]interface{}, titleChanged bool) (map[string]interface{}, bool) { + return buildOperateSheetOutput(token, data, "updateSheet", titleChanged) +} + +var SheetCreateSheet = common.Shortcut{ + Service: "sheets", + Command: "+create-sheet", + Description: "Create a sheet in an existing spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "title", Desc: "sheet title"}, + {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(buildCreateSheetBody(runtime)). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime)) + if err != nil { + return err + } + if out, ok := buildOperateSheetOutput(token, data, "addSheet", runtime.Changed("title")); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetCopySheet = common.Shortcut{ + Service: "sheets", + Command: "+copy-sheet", + Description: "Copy a sheet within a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "source sheet ID", Required: true}, + {Name: "title", Desc: "new sheet title"}, + {Name: "index", Type: "int", Desc: "new sheet index (0-based)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Desc("[1] Copy sheet"). + Body(buildCopySheetBody(runtime)). + Set("token", token) + if runtime.Changed("index") { + dry.POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Desc("[2] Move copied sheet to requested index"). + Body(buildMoveCopiedSheetBody("", runtime.Int("index"))). + Set("token", token) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime)) + if err != nil { + return err + } + out, ok := buildOperateSheetOutput(token, data, "copySheet", runtime.Changed("title")) + if !ok { + runtime.Out(data, nil) + return nil + } + if runtime.Changed("index") { + copiedSheetID, _ := out["sheet_id"].(string) + moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index"))) + if err != nil { + return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index")) + } + if moveOut, ok := buildUpdateSheetOutput(token, moveResp, false); ok { + out = mergeSheetOutputs(out, moveOut) + } + } + runtime.Out(out, nil) + return nil + }, +} + +var SheetDeleteSheet = common.Shortcut{ + Service: "sheets", + Command: "+delete-sheet", + Description: "Delete a sheet from a spreadsheet", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID to delete", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + return validateSheetID("sheet-id", runtime.Str("sheet-id")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(buildDeleteSheetBody(runtime.Str("sheet-id"))). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id"))) + if err != nil { + return err + } + if out, ok := buildDeleteSheetOutput(token, runtime.Str("sheet-id"), data); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateSheet = common.Shortcut{ + Service: "sheets", + Command: "+update-sheet", + Description: "Update sheet properties", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "title", Desc: "sheet title"}, + {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, + {Name: "hidden", Type: "bool", Desc: "set true to hide or false to unhide"}, + {Name: "frozen-row-count", Type: "int", Desc: "freeze rows through this count (0 unfreezes)"}, + {Name: "frozen-col-count", Type: "int", Desc: "freeze columns through this count (0 unfreezes)"}, + {Name: "lock", Desc: "sheet protection mode", Enum: sheetProtectLockValues}, + {Name: "lock-info", Desc: "protection remark"}, + {Name: "user-ids", Desc: `extra editor IDs for protected sheet as JSON array (e.g. '["ou_xxx"]')`}, + {Name: "user-id-type", Desc: "user ID type for --user-ids", Enum: []string{"open_id", "union_id", "lark_id", "user_id"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + return validateUpdateSheetFlags(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body, _ := buildUpdateSheetBody(runtime) + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(body). + Set("token", token) + if userIDType := runtime.Str("user-id-type"); userIDType != "" { + dry.Params(map[string]interface{}{"user_id_type": userIDType}) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + body, err := buildUpdateSheetBody(runtime) + if err != nil { + return err + } + var params map[string]interface{} + if userIDType := runtime.Str("user-id-type"); userIDType != "" { + params = map[string]interface{}{"user_id_type": userIDType} + } + + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body) + if err != nil { + return err + } + if out, ok := buildUpdateSheetOutput(token, data, runtime.Changed("title")); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_media_upload_test.go b/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go similarity index 86% rename from shortcuts/sheets/sheet_media_upload_test.go rename to shortcuts/sheets/lark_sheets_sheet_media_upload_test.go index e4d0d5146..181ebcdf7 100644 --- a/shortcuts/sheets/sheet_media_upload_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go @@ -27,6 +27,41 @@ func TestSheetMediaUploadValidateMissingToken(t *testing.T) { } } +func TestSheetMediaUploadValidateMissingFileBeforeDryRun(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", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "file not found") { + t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err) + } +} + +func TestSheetMediaUploadValidateRejectsDirectoryBeforeDryRun(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.Mkdir("imgdir", 0o755); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "imgdir", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("expected regular-file error before dry-run planning, got: %v", err) + } +} + func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { dir := t.TempDir() withSheetsTestWorkingDir(t, dir) diff --git a/shortcuts/sheets/sheet_ranges_test.go b/shortcuts/sheets/lark_sheets_sheet_ranges_test.go similarity index 52% rename from shortcuts/sheets/sheet_ranges_test.go rename to shortcuts/sheets/lark_sheets_sheet_ranges_test.go index b5eb2b6e4..a4b2a4eb4 100644 --- a/shortcuts/sheets/sheet_ranges_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_ranges_test.go @@ -146,3 +146,123 @@ func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got) } } + +func TestSheetFindValidateMismatchedRangeSheetID(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "target", + "range": "sheet2!A1:B2", + }, map[string]bool{ + "ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false, + }) + err := SheetFind.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected mismatch error, got: %v", err) + } +} + +func TestCellDataValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + boolFlags map[string]bool + }{ + { + name: "read", + shortcut: SheetRead, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN"}, + }, + { + name: "write", + shortcut: SheetWrite, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, + }, + { + name: "append", + shortcut: SheetAppend, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, + }, + { + name: "find", + shortcut: SheetFind, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "x"}, + boolFlags: map[string]bool{"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, + }, + { + name: "replace", + shortcut: SheetReplace, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "a", "replacement": "b"}, + boolFlags: map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, tt.strFlags, tt.boolFlags) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + +func TestCellDataValidateRejectsInvalidSpreadsheetURL(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/docx/doxcnNotSheet", + "spreadsheet-token": "", + }, nil) + err := SheetRead.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "spreadsheet URL") { + t.Fatalf("expected invalid spreadsheet URL error, got: %v", err) + } +} + +func TestCellDataValidateRejectsNon2DValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + }{ + { + name: "write 1d array", + shortcut: SheetWrite, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `[1,2]`}, + }, + { + name: "write object", + shortcut: SheetWrite, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `{"a":1}`}, + }, + { + name: "append string", + shortcut: SheetAppend, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `"x"`}, + }, + { + name: "append null", + shortcut: SheetAppend, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `null`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, tt.strFlags, nil) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "must be a 2D array") { + t.Fatalf("expected 2D-array validation error, got: %v", err) + } + }) + } +} diff --git a/shortcuts/sheets/sheet_write_image_test.go b/shortcuts/sheets/lark_sheets_sheet_write_image_test.go similarity index 84% rename from shortcuts/sheets/sheet_write_image_test.go rename to shortcuts/sheets/lark_sheets_sheet_write_image_test.go index b7e801943..9ab5fa388 100644 --- a/shortcuts/sheets/sheet_write_image_test.go +++ b/shortcuts/sheets/lark_sheets_sheet_write_image_test.go @@ -38,6 +38,8 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti return parent.Execute() } +const existingWriteImageTestFile = "./lark_sheets_cell_images.go" + // ── Validate ───────────────────────────────────────────────────────────────── func TestSheetWriteImageValidateRequiresToken(t *testing.T) { @@ -56,7 +58,7 @@ func TestSheetWriteImageValidateAcceptsURL(t *testing.T) { t.Parallel() runtime := newSheetsTestRuntime(t, map[string]string{ "url": "https://example.larksuite.com/sheets/shtABC123", - "image": "./logo.png", + "image": existingWriteImageTestFile, "range": "sheetId!A1:A1", "sheet-id": "", }, nil) @@ -70,7 +72,7 @@ func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) { t.Parallel() runtime := newSheetsTestRuntime(t, map[string]string{ "spreadsheet-token": "shtABC123", - "image": "./logo.png", + "image": existingWriteImageTestFile, "range": "sheetId!A1:A1", "sheet-id": "", }, nil) @@ -98,7 +100,7 @@ func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) { t.Parallel() runtime := newSheetsTestRuntime(t, map[string]string{ "spreadsheet-token": "shtABC123", - "image": "./logo.png", + "image": existingWriteImageTestFile, "range": "A1", "sheet-id": "sheet1", }, nil) @@ -126,7 +128,7 @@ func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) { t.Parallel() runtime := newSheetsTestRuntime(t, map[string]string{ "spreadsheet-token": "shtABC123", - "image": "./logo.png", + "image": existingWriteImageTestFile, "range": "sheet1!A1:A1", "sheet-id": "", }, nil) @@ -219,6 +221,83 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) { } } +func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./missing.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "image file not found") { + t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err) + } +} + +func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) { + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.Mkdir("imgdir", 0o755); err != nil { + t.Fatalf("Mkdir() error: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./imgdir", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("expected regular-file error before dry-run planning, got: %v", err) + } +} + +func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "/etc/passwd", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "unsafe image path") { + t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err) + } +} + +func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) { + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + fh, err := os.Create("huge.png") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(20*1024*1024 + 1); err != nil { + fh.Close() + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err = mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./huge.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") { + t.Fatalf("expected size error before dry-run planning, got: %v", err) + } +} + // ── Execute ────────────────────────────────────────────────────────────────── func TestSheetWriteImageExecuteSendsJSON(t *testing.T) { diff --git a/shortcuts/sheets/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/lark_sheets_spreadsheet_management.go new file mode 100644 index 000000000..95b89b766 --- /dev/null +++ b/shortcuts/sheets/lark_sheets_spreadsheet_management.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetInfo = common.Shortcut{ + Service: "sheets", + Command: "+info", + Description: "View spreadsheet and sheet information", + 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"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token"). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) + if err != nil { + return err + } + + var sheetsData interface{} + sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil) + if sheetsErr == nil { + if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok { + if d, ok := sheetsMap["data"].(map[string]interface{}); ok { + sheetsData = d + } + } + } + + runtime.Out(map[string]interface{}{ + "spreadsheet": spreadsheetData, + "sheets": sheetsData, + }, nil) + return nil + }, +} + +var SheetCreate = common.Shortcut{ + Service: "sheets", + Command: "+create", + Description: "Create a spreadsheet (optional header row and initial data)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "title", Desc: "spreadsheet title", Required: true}, + {Name: "folder-token", Desc: "target folder token"}, + {Name: "headers", Desc: "header row JSON array"}, + {Name: "data", Desc: "initial data JSON 2D array"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if headersStr := runtime.Str("headers"); headersStr != "" { + var headers []interface{} + if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { + return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + } + } + if dataStr := runtime.Str("data"); dataStr != "" { + var rows [][]interface{} + if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { + return common.FlagErrorf("--data invalid JSON, must be a 2D array") + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"title": runtime.Str("title")} + if folderToken := runtime.Str("folder-token"); folderToken != "" { + body["folder_token"] = folderToken + } + d := common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets"). + Body(body) + if runtime.IsBot() { + d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + title := runtime.Str("title") + folderToken := runtime.Str("folder-token") + headersStr := runtime.Str("headers") + dataStr := runtime.Str("data") + var allRows []interface{} + + if headersStr != "" { + var headers []interface{} + if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { + return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + } + if len(headers) > 0 { + allRows = append(allRows, any(headers)) + } + } + + if dataStr != "" { + var rows []interface{} + if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { + return common.FlagErrorf("--data invalid JSON, must be a 2D array") + } + if len(rows) > 0 { + allRows = append(allRows, rows...) + } + } + + createData := map[string]interface{}{"title": title} + if folderToken != "" { + createData["folder_token"] = folderToken + } + + data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) + if err != nil { + return err + } + + spreadsheet, _ := data["spreadsheet"].(map[string]interface{}) + token, _ := spreadsheet["spreadsheet_token"].(string) + + if len(allRows) > 0 && token != "" { + appendRange, err := getFirstSheetID(runtime, token) + if err != nil { + return err + } + if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": appendRange, + "values": allRows, + }, + }); err != nil { + return err + } + } + + out := map[string]interface{}{ + "spreadsheet_token": token, + "title": title, + } + url, _ := spreadsheet["url"].(string) + if url = strings.TrimSpace(url); url != "" { + out["url"] = url + } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { + out["url"] = u + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { + out["permission_grant"] = grant + } + + runtime.Out(out, nil) + return nil + }, +} + +var SheetExport = common.Shortcut{ + Service: "sheets", + Command: "+export", + Description: "Export a spreadsheet (async task polling + optional download)", + Risk: "read", + Scopes: []string{"docs:document:export", "drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "file-extension", Desc: "export format: xlsx | csv", Required: true, Enum: []string{"xlsx", "csv"}}, + {Name: "output-path", Desc: "local save path"}, + {Name: "sheet-id", Desc: "sheet ID (required for CSV)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { + return common.FlagErrorf("--sheet-id is required when --file-extension is csv") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": runtime.Str("file-extension"), + } + if sheetID := strings.TrimSpace(runtime.Str("sheet-id")); sheetID != "" { + body["sub_id"] = sheetID + } + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/export_tasks"). + Body(body). + Set("token", token).Set("ext", runtime.Str("file-extension")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + fileExt := runtime.Str("file-extension") + outputPath := runtime.Str("output-path") + sheetID := runtime.Str("sheet-id") + + if outputPath != "" { + if _, err := runtime.ResolveSavePath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + } + + exportData := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": fileExt, + } + if sheetID != "" { + exportData["sub_id"] = sheetID + } + + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) + if err != nil { + return err + } + ticket, _ := data["ticket"].(string) + + fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n") + var fileToken string + for i := 0; i < 50; i++ { + time.Sleep(600 * time.Millisecond) + pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil) + if err != nil { + continue + } + pollMap, _ := pollResult.(map[string]interface{}) + pollData, _ := pollMap["data"].(map[string]interface{}) + pollResult2, _ := pollData["result"].(map[string]interface{}) + if pollResult2 != nil { + ft, _ := pollResult2["file_token"].(string) + if ft != "" { + fileToken = ft + break + } + } + } + + if fileToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "export task timed out") + } + + fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken) + + if outputPath == "" { + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "ticket": ticket, + }, nil) + return nil + } + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return common.WrapSaveErrorByCategory(err, "io") + } + + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } + runtime.Out(map[string]interface{}{ + "saved_path": savedPath, + "size_bytes": result.Size(), + }, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_add_dimension.go b/shortcuts/sheets/sheet_add_dimension.go deleted file mode 100644 index 1a876e857..000000000 --- a/shortcuts/sheets/sheet_add_dimension.go +++ /dev/null @@ -1,81 +0,0 @@ -// 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" -) - -var SheetAddDimension = common.Shortcut{ - Service: "sheets", - Command: "+add-dimension", - Description: "Add rows or columns at the end of a sheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - length := runtime.Int("length") - if length < 1 || length > 5000 { - return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "length": runtime.Int("length"), - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "length": runtime.Int("length"), - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_append.go b/shortcuts/sheets/sheet_append.go deleted file mode 100644 index fcd1e0b68..000000000 --- a/shortcuts/sheets/sheet_append.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetAppend = common.Shortcut{ - Service: "sheets", - Command: "+append", - Description: "Append rows to a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "append range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "values", Desc: "2D array JSON", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - - var values interface{} - if err := json.Unmarshal([]byte(runtime.Str("values")), &values); err != nil { - return common.FlagErrorf("--values invalid JSON, must be a 2D array") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - var values interface{} - json.Unmarshal([]byte(runtime.Str("values")), &values) - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). - Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - var values interface{} - json.Unmarshal([]byte(runtime.Str("values")), &values) - - appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - - if appendRange == "" { - var err error - appendRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) - - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": appendRange, - "values": values, - }, - }) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_batch_set_style.go b/shortcuts/sheets/sheet_batch_set_style.go deleted file mode 100644 index 7d3e502cb..000000000 --- a/shortcuts/sheets/sheet_batch_set_style.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetBatchSetStyle = common.Shortcut{ - Service: "sheets", - Command: "+batch-set-style", - Description: "Batch set cell styles for multiple ranges", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - var data interface{} - if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) - } - arr, ok := data.([]interface{}) - if !ok || len(arr) == 0 { - return common.FlagErrorf("--data must be a non-empty JSON array") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - var data interface{} - json.Unmarshal([]byte(runtime.Str("data")), &data) - normalizeBatchStyleRanges(data) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). - Body(map[string]interface{}{ - "data": data, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - var data interface{} - if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) - } - normalizeBatchStyleRanges(data) - - result, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "data": data, - }, - ) - if err != nil { - return err - } - runtime.Out(result, nil) - return nil - }, -} - -// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place -// so the /styles_batch_update endpoint accepts single-cell shorthand. -// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to -// "sheet1!A1:A1"; multi-cell spans pass through unchanged. -// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be -// expanded because the helper has no sheet-id context (the shortcut exposes -// no --sheet-id flag), and the backend would reject the payload anyway — -// such entries pass through unchanged. Non-string entries, missing -// ranges keys, and non-array top-level inputs are ignored silently. -func normalizeBatchStyleRanges(data interface{}) { - items, ok := data.([]interface{}) - if !ok { - return - } - for _, item := range items { - entry, ok := item.(map[string]interface{}) - if !ok { - continue - } - ranges, ok := entry["ranges"].([]interface{}) - if !ok { - continue - } - for i, r := range ranges { - if s, ok := r.(string); ok { - ranges[i] = normalizePointRange("", s) - } - } - } -} diff --git a/shortcuts/sheets/sheet_create.go b/shortcuts/sheets/sheet_create.go deleted file mode 100644 index fba3534e8..000000000 --- a/shortcuts/sheets/sheet_create.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetCreate = common.Shortcut{ - Service: "sheets", - Command: "+create", - Description: "Create a spreadsheet (optional header row and initial data)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "title", Desc: "spreadsheet title", Required: true}, - {Name: "folder-token", Desc: "target folder token"}, - {Name: "headers", Desc: "header row JSON array"}, - {Name: "data", Desc: "initial data JSON 2D array"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if headersStr := runtime.Str("headers"); headersStr != "" { - var headers []interface{} - if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") - } - } - if dataStr := runtime.Str("data"); dataStr != "" { - var rows [][]interface{} - if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - d := common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets"). - Body(map[string]interface{}{"title": runtime.Str("title")}) - if runtime.IsBot() { - d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.") - } - return d - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - title := runtime.Str("title") - folderToken := runtime.Str("folder-token") - headersStr := runtime.Str("headers") - dataStr := runtime.Str("data") - var allRows []interface{} - - if headersStr != "" { - var headers []interface{} - if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") - } - if len(headers) > 0 { - allRows = append(allRows, headers) - } - } - - if dataStr != "" { - var rows []interface{} - if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") - } - if len(rows) > 0 { - allRows = append(allRows, rows...) - } - } - - createData := map[string]interface{}{"title": title} - if folderToken != "" { - createData["folder_token"] = folderToken - } - - data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) - if err != nil { - return err - } - - spreadsheet, _ := data["spreadsheet"].(map[string]interface{}) - token, _ := spreadsheet["spreadsheet_token"].(string) - - // Write headers and data if provided - if len(allRows) > 0 && token != "" { - appendRange, err := getFirstSheetID(runtime, token) - if err != nil { - return err - } - if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": appendRange, - "values": allRows, - }, - }); err != nil { - return err - } - } - - out := map[string]interface{}{ - "spreadsheet_token": token, - "title": title, - } - url, _ := spreadsheet["url"].(string) - if url = strings.TrimSpace(url); url != "" { - out["url"] = url - } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { - out["url"] = u - } - if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { - out["permission_grant"] = grant - } - - runtime.Out(out, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_delete_dimension.go b/shortcuts/sheets/sheet_delete_dimension.go deleted file mode 100644 index 27ac400f9..000000000 --- a/shortcuts/sheets/sheet_delete_dimension.go +++ /dev/null @@ -1,86 +0,0 @@ -// 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" -) - -var SheetDeleteDimension = common.Shortcut{ - Service: "sheets", - Command: "+delete-dimension", - Description: "Delete rows or columns", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - data, err := runtime.CallAPI("DELETE", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go deleted file mode 100644 index a9162bc7a..000000000 --- a/shortcuts/sheets/sheet_export.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "fmt" - "net/http" - "time" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetExport = common.Shortcut{ - Service: "sheets", - Command: "+export", - Description: "Export a spreadsheet (async task polling + optional download)", - Risk: "read", - Scopes: []string{"docs:document:export", "drive:file:download"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "file-extension", Desc: "export format: xlsx | csv", Required: true}, - {Name: "output-path", Desc: "local save path"}, - {Name: "sheet-id", Desc: "sheet ID (required for CSV)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - POST("/open-apis/drive/v1/export_tasks"). - Body(map[string]interface{}{"token": token, "type": "sheet", "file_extension": runtime.Str("file-extension")}). - Set("token", token).Set("ext", runtime.Str("file-extension")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - fileExt := runtime.Str("file-extension") - outputPath := runtime.Str("output-path") - sheetIdFlag := runtime.Str("sheet-id") - - // Early path validation before any API call - if outputPath != "" { - if _, err := runtime.ResolveSavePath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - } - - // Create export task - exportData := map[string]interface{}{ - "token": token, - "type": "sheet", - "file_extension": fileExt, - } - if sheetIdFlag != "" { - exportData["sub_id"] = sheetIdFlag - } - - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) - if err != nil { - return err - } - ticket, _ := data["ticket"].(string) - - // Poll for completion - fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n") - var fileToken string - for i := 0; i < 50; i++ { - time.Sleep(600 * time.Millisecond) - pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil) - if err != nil { - continue - } - pollMap, _ := pollResult.(map[string]interface{}) - pollData, _ := pollMap["data"].(map[string]interface{}) - pollResult2, _ := pollData["result"].(map[string]interface{}) - if pollResult2 != nil { - ft, _ := pollResult2["file_token"].(string) - if ft != "" { - fileToken = ft - break - } - } - } - - if fileToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "export task timed out") - } - - fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken) - - if outputPath == "" { - runtime.Out(map[string]interface{}{ - "file_token": fileToken, - "ticket": ticket, - }, nil) - } - - // Download - resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ - HttpMethod: http.MethodGet, - ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), - }) - if err != nil { - return output.ErrNetwork("download failed: %s", err) - } - defer resp.Body.Close() - - result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ - ContentType: resp.Header.Get("Content-Type"), - ContentLength: resp.ContentLength, - }, resp.Body) - if err != nil { - return common.WrapSaveErrorByCategory(err, "io") - } - - savedPath, _ := runtime.ResolveSavePath(outputPath) - if savedPath == "" { - savedPath = outputPath - } - runtime.Out(map[string]interface{}{ - "saved_path": savedPath, - "size_bytes": result.Size(), - }, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_filter_view.go b/shortcuts/sheets/sheet_filter_view.go deleted file mode 100644 index 3794d1669..000000000 --- a/shortcuts/sheets/sheet_filter_view.go +++ /dev/null @@ -1,239 +0,0 @@ -// 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 filterViewBasePath(token, sheetID string) string { - return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views", - validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) -} - -func filterViewItemPath(token, sheetID, filterViewID string) string { - return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID)) -} - -func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") - } - return token, nil -} - -var SheetCreateFilterView = common.Shortcut{ - Service: "sheets", - Command: "+create-filter-view", - Description: "Create a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true}, - {Name: "filter-view-name", Desc: "display name (max 100 chars)"}, - {Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{"range": runtime.Str("range")} - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - if s := runtime.Str("filter-view-id"); s != "" { - body["filter_view_id"] = s - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{"range": runtime.Str("range")} - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - if s := runtime.Str("filter-view-id"); s != "" { - body["filter_view_id"] = s - } - data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateFilterView = common.Shortcut{ - Service: "sheets", - Command: "+update-filter-view", - Description: "Update a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "range", Desc: "new filter range"}, - {Name: "filter-view-name", Desc: "new display name (max 100 chars)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFilterViewToken(runtime); err != nil { - return err - } - if !runtime.Cmd.Flags().Changed("range") && - !runtime.Cmd.Flags().Changed("filter-view-name") { - return common.FlagErrorf("specify at least one of --range or --filter-view-name") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{} - if s := runtime.Str("range"); s != "" { - body["range"] = s - } - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - return common.NewDryRunAPI(). - PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{} - if s := runtime.Str("range"); s != "" { - body["range"] = s - } - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetListFilterViews = common.Shortcut{ - Service: "sheets", - Command: "+list-filter-views", - Description: "List all filter views in a sheet", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetGetFilterView = common.Shortcut{ - Service: "sheets", - Command: "+get-filter-view", - Description: "Get a filter view by ID", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetDeleteFilterView = common.Shortcut{ - Service: "sheets", - Command: "+delete-filter-view", - Description: "Delete a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_find.go b/shortcuts/sheets/sheet_find.go deleted file mode 100644 index 66c8bf9a7..000000000 --- a/shortcuts/sheets/sheet_find.go +++ /dev/null @@ -1,101 +0,0 @@ -// 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" -) - -var SheetFind = common.Shortcut{ - Service: "sheets", - Command: "+find", - Description: "Find cells in a spreadsheet", - 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: "find", Desc: "search text", Required: true}, - {Name: "range", Desc: "search range (!A1:D10, or A1:D10 / C2 with --sheet-id)"}, - {Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"}, - {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"}, - {Name: "search-by-regex", Type: "bool", Desc: "regex search"}, - {Name: "include-formulas", Type: "bool", Desc: "search formulas"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - sheetIdFlag := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetIdFlag, - "match_case": !runtime.Bool("ignore-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizePointRange(sheetIdFlag, runtime.Str("range")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find"). - Body(map[string]interface{}{ - "find": runtime.Str("find"), - "find_condition": findCondition, - }). - Set("token", token).Set("sheet_id", sheetIdFlag).Set("find", runtime.Str("find")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - sheetIdFlag := runtime.Str("sheet-id") - findText := runtime.Str("find") - - findCondition := map[string]interface{}{ - "range": sheetIdFlag, - "match_case": !runtime.Bool("ignore-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizePointRange(sheetIdFlag, runtime.Str("range")) - } - - reqData := map[string]interface{}{ - "find_condition": findCondition, - "find": findText, - } - - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetIdFlag)), nil, reqData) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_info.go b/shortcuts/sheets/sheet_info.go deleted file mode 100644 index aac7fd9ea..000000000 --- a/shortcuts/sheets/sheet_info.go +++ /dev/null @@ -1,73 +0,0 @@ -// 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" -) - -var SheetInfo = common.Shortcut{ - Service: "sheets", - Command: "+info", - Description: "View spreadsheet and sheet information", - 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"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token"). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - // Get spreadsheet info - spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) - if err != nil { - return err - } - - // Get sheets info (best-effort) - var sheetsData interface{} - sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil) - if sheetsErr == nil { - if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok { - if d, ok := sheetsMap["data"].(map[string]interface{}); ok { - sheetsData = d - } - } - } - - runtime.Out(map[string]interface{}{ - "spreadsheet": spreadsheetData, - "sheets": sheetsData, - }, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_insert_dimension.go b/shortcuts/sheets/sheet_insert_dimension.go deleted file mode 100644 index 00cec9b93..000000000 --- a/shortcuts/sheets/sheet_insert_dimension.go +++ /dev/null @@ -1,95 +0,0 @@ -// 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" -) - -var SheetInsertDimension = common.Shortcut{ - Service: "sheets", - Command: "+insert-dimension", - Description: "Insert rows or columns at a specified position", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true}, - {Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") - } - if runtime.Int("end-index") <= runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be greater than --start-index") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - body := map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - } - if s := runtime.Str("inherit-style"); s != "" { - body["inheritStyle"] = s - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range"). - Body(body). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - body := map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - } - if s := runtime.Str("inherit-style"); s != "" { - body["inheritStyle"] = s - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), - nil, body, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_media_upload.go b/shortcuts/sheets/sheet_media_upload.go deleted file mode 100644 index 93963d3ef..000000000 --- a/shortcuts/sheets/sheet_media_upload.go +++ /dev/null @@ -1,172 +0,0 @@ -// 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_merge_cells.go b/shortcuts/sheets/sheet_merge_cells.go deleted file mode 100644 index 6b471a5da..000000000 --- a/shortcuts/sheets/sheet_merge_cells.go +++ /dev/null @@ -1,77 +0,0 @@ -// 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" -) - -var SheetMergeCells = common.Shortcut{ - Service: "sheets", - Command: "+merge-cells", - Description: "Merge cells in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - {Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells"). - Body(map[string]interface{}{ - "range": r, - "mergeType": runtime.Str("merge-type"), - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "range": r, - "mergeType": runtime.Str("merge-type"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_move_dimension.go b/shortcuts/sheets/sheet_move_dimension.go deleted file mode 100644 index e769cc905..000000000 --- a/shortcuts/sheets/sheet_move_dimension.go +++ /dev/null @@ -1,94 +0,0 @@ -// 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" -) - -var SheetMoveDimension = common.Shortcut{ - Service: "sheets", - Command: "+move-dimension", - Description: "Move rows or columns to a new position", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true}, - {Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true}, - {Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - if runtime.Int("destination-index") < 0 { - return common.FlagErrorf("--destination-index must be >= 0") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension"). - Body(map[string]interface{}{ - "source": map[string]interface{}{ - "major_dimension": runtime.Str("dimension"), - "start_index": runtime.Int("start-index"), - "end_index": runtime.Int("end-index"), - }, - "destination_index": runtime.Int("destination-index"), - }). - Set("token", token). - Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", - validate.EncodePathSegment(token), - validate.EncodePathSegment(runtime.Str("sheet-id")), - ), - nil, - map[string]interface{}{ - "source": map[string]interface{}{ - "major_dimension": runtime.Str("dimension"), - "start_index": runtime.Int("start-index"), - "end_index": runtime.Int("end-index"), - }, - "destination_index": runtime.Int("destination-index"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_read.go b/shortcuts/sheets/sheet_read.go deleted file mode 100644 index 65c3f4974..000000000 --- a/shortcuts/sheets/sheet_read.go +++ /dev/null @@ -1,88 +0,0 @@ -// 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" -) - -var SheetRead = common.Shortcut{ - Service: "sheets", - Command: "+read", - Description: "Read spreadsheet cell values", - 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: "range", Desc: "read range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). - Set("token", token).Set("range", readRange) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - - if readRange == "" { - var err error - readRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) - - params := map[string]interface{}{} - renderOption := runtime.Str("value-render-option") - if renderOption != "" { - params["valueRenderOption"] = renderOption - } - - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_replace.go b/shortcuts/sheets/sheet_replace.go deleted file mode 100644 index a24fe168a..000000000 --- a/shortcuts/sheets/sheet_replace.go +++ /dev/null @@ -1,112 +0,0 @@ -// 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" -) - -var SheetReplace = common.Shortcut{ - Service: "sheets", - Command: "+replace", - Description: "Find and replace cell values in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "find", Desc: "search text or regex pattern", Required: true}, - {Name: "replacement", Desc: "replacement text", Required: true}, - {Name: "range", Desc: "search range (!A1:D10, or A1:D10 with --sheet-id)"}, - {Name: "match-case", Type: "bool", Desc: "case-sensitive search"}, - {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"}, - {Name: "search-by-regex", Type: "bool", Desc: "use regex search"}, - {Name: "include-formulas", Type: "bool", Desc: "search in formulas"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - if r := runtime.Str("range"); r != "" { - if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - sheetID := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": runtime.Bool("match-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace"). - Body(map[string]interface{}{ - "find_condition": findCondition, - "find": runtime.Str("find"), - "replacement": runtime.Str("replacement"), - }). - Set("token", token).Set("sheet_id", sheetID) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - sheetID := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": runtime.Bool("match-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace", - validate.EncodePathSegment(token), - validate.EncodePathSegment(sheetID), - ), - nil, - map[string]interface{}{ - "find_condition": findCondition, - "find": runtime.Str("find"), - "replacement": runtime.Str("replacement"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_set_style.go b/shortcuts/sheets/sheet_set_style.go deleted file mode 100644 index 15d953adb..000000000 --- a/shortcuts/sheets/sheet_set_style.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetSetStyle = common.Shortcut{ - Service: "sheets", - Command: "+set-style", - Description: "Set cell style for a range", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - {Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - var style interface{} - if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) - } - if _, ok := style.(map[string]interface{}); !ok { - return common.FlagErrorf("--style must be a JSON object, got %T", style) - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - var style interface{} - json.Unmarshal([]byte(runtime.Str("style")), &style) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/style"). - Body(map[string]interface{}{ - "appendStyle": map[string]interface{}{ - "range": r, - "style": style, - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - var style interface{} - if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) - } - - data, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "appendStyle": map[string]interface{}{ - "range": r, - "style": style, - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_unmerge_cells.go b/shortcuts/sheets/sheet_unmerge_cells.go deleted file mode 100644 index f79d2e5c3..000000000 --- a/shortcuts/sheets/sheet_unmerge_cells.go +++ /dev/null @@ -1,74 +0,0 @@ -// 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" -) - -var SheetUnmergeCells = common.Shortcut{ - Service: "sheets", - Command: "+unmerge-cells", - Description: "Unmerge (split) cells in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells"). - Body(map[string]interface{}{ - "range": r, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "range": r, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_update_dimension.go b/shortcuts/sheets/sheet_update_dimension.go deleted file mode 100644 index a30f506ab..000000000 --- a/shortcuts/sheets/sheet_update_dimension.go +++ /dev/null @@ -1,111 +0,0 @@ -// 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" -) - -var SheetUpdateDimension = common.Shortcut{ - Service: "sheets", - Command: "+update-dimension", - Description: "Update row or column properties (visibility, size)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, - {Name: "visible", Type: "bool", Desc: "true to show, false to hide"}, - {Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { - return common.FlagErrorf("specify at least one of --visible or --fixed-size") - } - if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { - return common.FlagErrorf("--fixed-size must be >= 1") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - props := map[string]interface{}{} - if runtime.Cmd.Flags().Changed("visible") { - props["visible"] = runtime.Bool("visible") - } - if runtime.Cmd.Flags().Changed("fixed-size") { - props["fixedSize"] = runtime.Int("fixed-size") - } - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - "dimensionProperties": props, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - props := map[string]interface{}{} - if runtime.Cmd.Flags().Changed("visible") { - props["visible"] = runtime.Bool("visible") - } - if runtime.Cmd.Flags().Changed("fixed-size") { - props["fixedSize"] = runtime.Int("fixed-size") - } - - data, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - "dimensionProperties": props, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_write.go b/shortcuts/sheets/sheet_write.go deleted file mode 100644 index dd74db20c..000000000 --- a/shortcuts/sheets/sheet_write.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetWrite = common.Shortcut{ - Service: "sheets", - Command: "+write", - Description: "Write to spreadsheet cells (overwrite mode)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "write range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "values", Desc: "2D array JSON", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - - var values interface{} - if err := json.Unmarshal([]byte(runtime.Str("values")), &values); err != nil { - return common.FlagErrorf("--values invalid JSON, must be a 2D array") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - var values interface{} - json.Unmarshal([]byte(runtime.Str("values")), &values) - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). - Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - var values interface{} - json.Unmarshal([]byte(runtime.Str("values")), &values) - - writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - - if writeRange == "" { - var err error - writeRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) - - data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": writeRange, - "values": values, - }, - }) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 4f5543dd5..ac5e94487 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -8,24 +8,41 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all sheets shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + // Spreadsheet management + SheetCreate, SheetInfo, + SheetExport, + + // Sheet management + SheetCreateSheet, + SheetCopySheet, + SheetDeleteSheet, + SheetUpdateSheet, + + // Cell data SheetRead, SheetWrite, - SheetWriteImage, SheetAppend, SheetFind, - SheetCreate, - SheetExport, - SheetMergeCells, - SheetUnmergeCells, SheetReplace, + + // Cell style and merge SheetSetStyle, SheetBatchSetStyle, + SheetMergeCells, + SheetUnmergeCells, + + // Cell images + SheetWriteImage, + + // Row/column management SheetAddDimension, SheetInsertDimension, SheetUpdateDimension, SheetMoveDimension, SheetDeleteDimension, + + // Filter views SheetCreateFilterView, SheetUpdateFilterView, SheetListFilterViews, @@ -36,10 +53,14 @@ func Shortcuts() []common.Shortcut { SheetListFilterViewConditions, SheetGetFilterViewCondition, SheetDeleteFilterViewCondition, + + // Dropdown SheetSetDropdown, SheetUpdateDropdown, SheetGetDropdown, SheetDeleteDropdown, + + // Float images SheetMediaUpload, SheetCreateFloatImage, SheetUpdateFloatImage, diff --git a/skill-template/domains/sheets.md b/skill-template/domains/sheets.md index 53dea9faa..8890c0241 100644 --- a/skill-template/domains/sheets.md +++ b/skill-template/domains/sheets.md @@ -162,4 +162,4 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ **限制**: - 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用) - @人仅支持同租户用户,单次最多 50 人 -- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号 +- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号 \ No newline at end of file diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 91c893f61..15b9733b3 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-sheets -version: 1.1.0 -description: "飞书电子表格:创建和操作电子表格。创建表格并写入表头和数据、读取和写入单元格、追加行数据、在已知电子表格中查找单元格内容、导出表格文件。当用户需要创建电子表格、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 docs +search 先定位资源。" +version: 1.2.0 +description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 docs +search 先定位资源。" metadata: requires: bins: ["lark-cli"] @@ -175,62 +175,120 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ **限制**: - 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用) - @人仅支持同租户用户,单次最多 50 人 -- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-set-dropdown.md`](references/lark-sheets-set-dropdown.md)。值中的字符串不能包含逗号 +- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-dropdown.md#set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown)。值中的字符串不能包含逗号 ## Shortcuts(推荐优先使用) Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]`)。有 Shortcut 的操作优先使用。 +### Spreadsheet Management + +对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md) + +| Shortcut | 说明 | +|----------|------| +| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) | +| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet and sheet information | +| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) | + +### Sheet Management + +对应参考文档:[sheet-management](references/lark-sheets-sheet-management.md) + +| Shortcut | 说明 | +|----------|------| +| [`+create-sheet`](references/lark-sheets-sheet-management.md#create-sheet) | Create a sheet in an existing spreadsheet | +| [`+copy-sheet`](references/lark-sheets-sheet-management.md#copy-sheet) | Copy a sheet within a spreadsheet | +| [`+delete-sheet`](references/lark-sheets-sheet-management.md#delete-sheet) | Delete a sheet from a spreadsheet | +| [`+update-sheet`](references/lark-sheets-sheet-management.md#update-sheet) | Update sheet title, position, visibility, freeze, or protection | + +### Cell Data + +对应参考文档:[cell-data](references/lark-sheets-cell-data.md) + +| Shortcut | 说明 | +|----------|------| +| [`+read`](references/lark-sheets-cell-data.md#read) | Read spreadsheet cell values | +| [`+write`](references/lark-sheets-cell-data.md#write) | Write to spreadsheet cells (overwrite mode) | +| [`+append`](references/lark-sheets-cell-data.md#append) | Append rows to a spreadsheet | +| [`+find`](references/lark-sheets-cell-data.md#find) | Find cells in a spreadsheet | +| [`+replace`](references/lark-sheets-cell-data.md#replace) | Find and replace cell values | + +### Cell Style And Merge + +对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md) + +| Shortcut | 说明 | +|----------|------| +| [`+set-style`](references/lark-sheets-cell-style-and-merge.md#set-style) | Set cell style for a range | +| [`+batch-set-style`](references/lark-sheets-cell-style-and-merge.md#batch-set-style) | Batch set cell styles for multiple ranges | +| [`+merge-cells`](references/lark-sheets-cell-style-and-merge.md#merge-cells) | Merge cells in a spreadsheet | +| [`+unmerge-cells`](references/lark-sheets-cell-style-and-merge.md#unmerge-cells) | Unmerge (split) cells in a spreadsheet | + +### Cell Images + +对应参考文档:[cell-images](references/lark-sheets-cell-images.md) + | Shortcut | 说明 | |----------|------| -| [`+info`](references/lark-sheets-info.md) | View spreadsheet and sheet information | -| [`+read`](references/lark-sheets-read.md) | Read spreadsheet cell values | -| [`+write`](references/lark-sheets-write.md) | Write to spreadsheet cells (overwrite mode) | -| [`+write-image`](references/lark-sheets-write-image.md) | Write an image into a spreadsheet cell | -| [`+append`](references/lark-sheets-append.md) | Append rows to a spreadsheet | -| [`+find`](references/lark-sheets-find.md) | Find cells in a spreadsheet | -| [`+create`](references/lark-sheets-create.md) | Create a spreadsheet (optional header row and initial data) | -| [`+export`](references/lark-sheets-export.md) | Export a spreadsheet (async task polling + optional download) | -| [`+merge-cells`](references/lark-sheets-merge-cells.md) | Merge cells in a spreadsheet | -| [`+unmerge-cells`](references/lark-sheets-unmerge-cells.md) | Unmerge (split) cells in a spreadsheet | -| [`+replace`](references/lark-sheets-replace.md) | Find and replace cell values | -| [`+set-style`](references/lark-sheets-set-style.md) | Set cell style for a range | -| [`+batch-set-style`](references/lark-sheets-batch-set-style.md) | Batch set cell styles for multiple ranges | -| [`+add-dimension`](references/lark-sheets-add-dimension.md) | Add rows or columns at the end of a sheet | -| [`+insert-dimension`](references/lark-sheets-insert-dimension.md) | Insert rows or columns at a specified position | -| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) | -| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position | -| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns | -| [`+create-filter-view`](references/lark-sheets-create-filter-view.md) | Create a filter view | -| [`+update-filter-view`](references/lark-sheets-update-filter-view.md) | Update a filter view | -| [`+list-filter-views`](references/lark-sheets-list-filter-views.md) | List all filter views in a sheet | -| [`+get-filter-view`](references/lark-sheets-get-filter-view.md) | Get a filter view by ID | -| [`+delete-filter-view`](references/lark-sheets-delete-filter-view.md) | Delete a filter view | -| [`+create-filter-view-condition`](references/lark-sheets-create-filter-view-condition.md) | Create a filter condition on a filter view | -| [`+update-filter-view-condition`](references/lark-sheets-update-filter-view-condition.md) | Update a filter condition | -| [`+list-filter-view-conditions`](references/lark-sheets-list-filter-view-conditions.md) | List all filter conditions of a filter view | -| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column | -| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition | - -### 下拉列表 +| [`+write-image`](references/lark-sheets-cell-images.md#write-image) | Write an image into a spreadsheet cell | + +### Row Column Management + +对应参考文档:[row-column-management](references/lark-sheets-row-column-management.md) | Shortcut | 说明 | |----------|------| -| [`+set-dropdown`](references/lark-sheets-set-dropdown.md) | 设置下拉列表(`multipleValue` 写入的前置步骤) | -| [`+update-dropdown`](references/lark-sheets-update-dropdown.md) | 更新下拉列表选项 | -| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 | -| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 | +| [`+add-dimension`](references/lark-sheets-row-column-management.md#add-dimension) | Add rows or columns at the end of a sheet | +| [`+insert-dimension`](references/lark-sheets-row-column-management.md#insert-dimension) | Insert rows or columns at a specified position | +| [`+update-dimension`](references/lark-sheets-row-column-management.md#update-dimension) | Update row or column properties (visibility, size) | +| [`+move-dimension`](references/lark-sheets-row-column-management.md#move-dimension) | Move rows or columns to a new position | +| [`+delete-dimension`](references/lark-sheets-row-column-management.md#delete-dimension) | Delete rows or columns | -### 浮动图片 +### Filter Views + +对应参考文档:[filter-views](references/lark-sheets-filter-views.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) | 删除浮动图片 | +| [`+create-filter-view`](references/lark-sheets-filter-views.md#create-filter-view) | Create a filter view | +| [`+update-filter-view`](references/lark-sheets-filter-views.md#update-filter-view) | Update a filter view | +| [`+list-filter-views`](references/lark-sheets-filter-views.md#list-filter-views) | List all filter views in a sheet | +| [`+get-filter-view`](references/lark-sheets-filter-views.md#get-filter-view) | Get a filter view by ID | +| [`+delete-filter-view`](references/lark-sheets-filter-views.md#delete-filter-view) | Delete a filter view | +| [`+create-filter-view-condition`](references/lark-sheets-filter-views.md#create-filter-view-condition) | Create a filter condition on a filter view | +| [`+update-filter-view-condition`](references/lark-sheets-filter-views.md#update-filter-view-condition) | Update a filter condition | +| [`+list-filter-view-conditions`](references/lark-sheets-filter-views.md#list-filter-view-conditions) | List all filter conditions of a filter view | +| [`+get-filter-view-condition`](references/lark-sheets-filter-views.md#get-filter-view-condition) | Get a filter condition by column | +| [`+delete-filter-view-condition`](references/lark-sheets-filter-views.md#delete-filter-view-condition) | Delete a filter condition | + +### Dropdown + +对应参考文档:[dropdown](references/lark-sheets-dropdown.md) + +| Shortcut | 说明 | +|----------|------| +| [`+set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown) | 设置下拉列表(`multipleValue` 写入的前置步骤) | +| [`+update-dropdown`](references/lark-sheets-dropdown.md#update-dropdown) | 更新下拉列表选项 | +| [`+get-dropdown`](references/lark-sheets-dropdown.md#get-dropdown) | 查询下拉列表配置 | +| [`+delete-dropdown`](references/lark-sheets-dropdown.md#delete-dropdown) | 删除下拉列表 | + +### Float Images + +对应参考文档:[float-images](references/lark-sheets-float-images.md) + +| Shortcut | 说明 | +|----------|------| +| [`+media-upload`](references/lark-sheets-float-images.md#media-upload) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) | +| [`+create-float-image`](references/lark-sheets-float-images.md#create-float-image) | 创建浮动图片 | +| [`+update-float-image`](references/lark-sheets-float-images.md#update-float-image) | 更新浮动图片属性 | +| [`+get-float-image`](references/lark-sheets-float-images.md#get-float-image) | 获取浮动图片 | +| [`+list-float-images`](references/lark-sheets-float-images.md#list-float-images) | 查询所有浮动图片 | +| [`+delete-float-image`](references/lark-sheets-float-images.md#delete-float-image) | 删除浮动图片 | + +### Formula + +对应参考文档:[formula](references/lark-sheets-formula.md) > 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "" --output ./image.png`。 @@ -285,4 +343,3 @@ lark-cli sheets [flags] # 调用 API | `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-add-dimension.md b/skills/lark-sheets/references/lark-sheets-add-dimension.md deleted file mode 100644 index 718da33ef..000000000 --- a/skills/lark-sheets/references/lark-sheets-add-dimension.md +++ /dev/null @@ -1,51 +0,0 @@ - -# sheets +add-dimension(增加行列) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +add-dimension`。 - -在工作表末尾追加空行或空列,不影响已有数据。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 在末尾追加 10 行 -lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --length 10 - -# 在末尾追加 3 列 -lark-cli sheets +add-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --dimension COLUMNS --length 3 - -# 仅预览参数(不发请求) -lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --length 5 --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id ` | 是 | 工作表 ID | -| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | -| `--length ` | 是 | 追加数量(1-5000) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `addCount`:实际追加的行/列数 -- `majorDimension`:`ROWS` 或 `COLUMNS` - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 -- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-append.md b/skills/lark-sheets/references/lark-sheets-append.md deleted file mode 100644 index 6417a8fef..000000000 --- a/skills/lark-sheets/references/lark-sheets-append.md +++ /dev/null @@ -1,56 +0,0 @@ - -# sheets +append(追加行) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +append`。 - -- `--values` 必须是二维数组 JSON -- 内置尺寸校验:最多 5000 行、每行最多 100 列 -- `--range` 可以是 `` 或 `!A1:D10` -- 若已传 `--sheet-id`,`--range` 也可写 `A1:D10` 或 `C2` - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 追加一行(6 列示例) -lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1" \ - --values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' - -# 配合 --sheet-id,可直接写相对范围 -lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "A1" \ - --values '[["A","B"]]' - -# 仅预览参数(不发请求) -lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" --range "!A1" \ - --values '[["A","B"]]' --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | -| `--range ` | 否 | 追加范围:`!A1:D10`、`A1:D10` / `C2`(需配合 `--sheet-id`),或 `` | -| `--sheet-id ` | 否 | 工作表 ID(不提供 `--range` 时生效) | -| `--values ` | 是 | 二维数组 JSON(追加的行数据) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `table_range` -- `updated_range/updated_rows/updated_columns/updated_cells` -- `revision` - -## 参考 - -- [lark-sheets-read](lark-sheets-read.md) — 追加后可 read 验证 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-batch-set-style.md b/skills/lark-sheets/references/lark-sheets-batch-set-style.md deleted file mode 100644 index 832c3be92..000000000 --- a/skills/lark-sheets/references/lark-sheets-batch-set-style.md +++ /dev/null @@ -1,53 +0,0 @@ - -# sheets +batch-set-style(批量设置单元格样式) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +batch-set-style`。 - -对多个范围批量设置不同的单元格样式,一次请求可包含多组范围和样式。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 对两组范围分别设置样式 -lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ - --data '[{"ranges":["!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["!D1:F3"],"style":{"foreColor":"#ff0000"}}]' - -# 同一样式应用到多个范围 -lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ - --data '[{"ranges":["!A1:B2","!D4:E5"],"style":{"hAlign":1,"font":{"bold":true}}}]' - -# 仅预览 -lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ - --data '[{"ranges":["!A1:B2"],"style":{"backColor":"#0000ff"}}]' --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | -| `--data ` | 是 | JSON 数组,每项包含 `ranges`(字符串数组)和 `style`(样式对象) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -### style 对象字段 - -与 `+set-style` 相同,参见 [lark-sheets-set-style](lark-sheets-set-style.md)。 - -## 输出 - -JSON,包含: - -- `totalUpdatedRows/totalUpdatedColumns/totalUpdatedCells`:汇总更新量 -- `revision`:工作表版本号 -- `responses[]`:每个范围的更新详情 - -## 参考 - -- [lark-sheets-set-style](lark-sheets-set-style.md) — 单范围设置样式 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-cell-data.md b/skills/lark-sheets/references/lark-sheets-cell-data.md new file mode 100644 index 000000000..dca3d20c8 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-cell-data.md @@ -0,0 +1,197 @@ +# Sheets Cell Data + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总单元格数据操作: + +- `+read` +- `+write` +- `+append` +- `+find` +- `+replace` + + +## `+read` + +对应命令:`lark-cli sheets +read` + +内置能力: + +- 支持 `--url` / `--spreadsheet-token` 二选一(URL 支持 wiki) +- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2` +- 默认最多返回 200 行 + +```bash +lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --range "!A1:H20" + +lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --range "C2" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 否 | `!A1:D10`、`A1:D10` / `C2` 或 `` | +| `--sheet-id` | 否 | 工作表 ID | +| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `range` +- `values` +- `truncated` +- `total_rows` + + +## `+write` + +对应命令:`lark-cli sheets +write` + +用于覆盖写入一个矩形区域。 + +```bash +lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:B2" \ + --values '[["name","age"],["alice",18]]' + +lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --range "C2" \ + --values '[["hello"]]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 否 | 写入范围;可用相对范围或 `` | +| `--sheet-id` | 否 | 工作表 ID | +| `--values` | 是 | 二维数组 JSON | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `updated_range` +- `updated_rows` +- `updated_columns` +- `updated_cells` +- `revision` + + +## `+append` + +对应命令:`lark-cli sheets +append` + +用于向工作表末尾追加行。 + +```bash +lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1" \ + --values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 否 | 追加范围:支持 ``、完整范围、相对范围 | +| `--sheet-id` | 否 | 工作表 ID | +| `--values` | 是 | 二维数组 JSON | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `table_range` +- `updated_range` +- `updated_rows` +- `updated_columns` +- `updated_cells` +- `revision` + + +## `+find` + +对应命令:`lark-cli sheets +find` + +只在一个已知 spreadsheet 内查找单元格内容,不是云空间搜索。 + +```bash +lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --find "张三" --range "A1:H200" + +lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --find "仓库管理营收报表" --ignore-case +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--find` | 是 | 查找内容 | +| `--range` | 否 | 范围;不填则搜索整个工作表 | +| `--ignore-case` | 否 | 不区分大小写 | +| `--match-entire-cell` | 否 | 完全匹配单元格 | +| `--search-by-regex` | 否 | 使用正则 | +| `--include-formulas` | 否 | 搜索公式 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `matched_cells` +- `matched_formula_cells` +- `rows_count` + + +## `+replace` + +对应命令:`lark-cli sheets +replace` + +在指定范围内查找并替换单元格内容。 + +```bash +lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --find "hello" --replacement "world" + +lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --find "\\d{4}-\\d{2}-\\d{2}" \ + --replacement "DATE" --search-by-regex +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--find` | 是 | 搜索文本 | +| `--replacement` | 是 | 替换文本 | +| `--range` | 否 | 搜索范围,不传则搜索整个工作表 | +| `--match-case` | 否 | 区分大小写 | +| `--match-entire-cell` | 否 | 匹配整个单元格 | +| `--search-by-regex` | 否 | 使用正则 | +| `--include-formulas` | 否 | 在公式中搜索 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `replace_result.matched_cells` +- `replace_result.matched_formula_cells` +- `replace_result.rows_count` + +## 参考 + +- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` +- [dropdown](lark-sheets-dropdown.md#set-dropdown) — 写入 `multipleValue` 前先设置下拉列表 +- [formula](lark-sheets-formula.md) — 公式写入规则 diff --git a/skills/lark-sheets/references/lark-sheets-cell-images.md b/skills/lark-sheets/references/lark-sheets-cell-images.md new file mode 100644 index 000000000..b06c1b553 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-cell-images.md @@ -0,0 +1,59 @@ +# Sheets Cell Images + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总单元格图片写入能力: + +- `+write-image` + + +## `+write-image` + +对应命令:`lark-cli sheets +write-image` + +特性: + +- 将本地图片文件写入到指定单元格 +- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC +- `--range` 必须表示单个单元格,如 `A1` 或 `!B2:B2` +- `--name` 默认取 `--image` 的文件名 + +```bash +# 写入图片到指定单元格 +lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ + --range "!B2:B2" \ + --image "./logo.png" + +# 使用 URL + sheet-id,指定单个单元格 +lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --range "C3" \ + --image "./chart.jpg" + +# 自定义图片名称 +lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:A1" \ + --image "./output.png" --name "revenue_chart.png" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 目标单元格:`!A1:A1` 或相对单元格 | +| `--sheet-id` | 否 | 工作表 ID | +| `--image` | 是 | 本地图片文件的相对路径 | +| `--name` | 否 | 图片文件名(默认取 `--image` 的文件名) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `spreadsheetToken` +- `updateRange` +- `revision` + +## 参考 + +- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 +- [float-images](lark-sheets-float-images.md) — 管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md b/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md new file mode 100644 index 000000000..8ca135da5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md @@ -0,0 +1,141 @@ +# Sheets Cell Style and Merge + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总单元格样式和合并相关操作: + +- `+set-style` +- `+batch-set-style` +- `+merge-cells` +- `+unmerge-cells` + + +## `+set-style` + +对应命令:`lark-cli sheets +set-style` + +对指定范围设置字体、颜色、对齐、边框等样式。 + +```bash +lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:C3" \ + --style '{"font":{"bold":true},"backColor":"#ff0000"}' + +lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:Z100" --style '{"clean":true}' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 单元格范围 | +| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | +| `--style` | 是 | 样式 JSON 对象 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +常用 `style` 字段: + +- `font.bold` +- `font.italic` +- `font.font_size` +- `textDecoration` +- `formatter` +- `hAlign` +- `vAlign` +- `foreColor` +- `backColor` +- `borderType` +- `borderColor` +- `clean` + +输出:`updates`(updatedRange / updatedRows / updatedColumns / updatedCells / revision) + + +## `+batch-set-style` + +对应命令:`lark-cli sheets +batch-set-style` + +对多个范围批量设置不同样式。 + +```bash +lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ + --data '[{"ranges":["!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["!D1:F3"],"style":{"foreColor":"#ff0000"}}]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--data` | 是 | JSON 数组,每项包含 `ranges` 和 `style` | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `totalUpdatedRows` +- `totalUpdatedColumns` +- `totalUpdatedCells` +- `revision` +- `responses[]` + + +## `+merge-cells` + +对应命令:`lark-cli sheets +merge-cells` + +支持三种模式: + +- `MERGE_ALL` +- `MERGE_ROWS` +- `MERGE_COLUMNS` + +```bash +lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:B2" --merge-type MERGE_ALL +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 单元格范围 | +| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | +| `--merge-type` | 是 | `MERGE_ALL` / `MERGE_ROWS` / `MERGE_COLUMNS` | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`spreadsheetToken` + + +## `+unmerge-cells` + +对应命令:`lark-cli sheets +unmerge-cells` + +用于拆分合并单元格。 + +```bash +lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ + --range "!A1:B2" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 单元格范围 | +| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`spreadsheetToken` + +## 参考 + +- [cell-data](lark-sheets-cell-data.md) — 数据读写 +- [cell-images](lark-sheets-cell-images.md) — 写入单元格图片 diff --git a/skills/lark-sheets/references/lark-sheets-create-filter-view-condition.md b/skills/lark-sheets/references/lark-sheets-create-filter-view-condition.md deleted file mode 100644 index 48f3f7eff..000000000 --- a/skills/lark-sheets/references/lark-sheets-create-filter-view-condition.md +++ /dev/null @@ -1,42 +0,0 @@ - -# sheets +create-filter-view-condition(创建筛选条件) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +create-filter-view-condition`。 - -为筛选视图的指定列创建筛选条件。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。 - -## 命令 - -```bash -# 数值筛选:E 列 < 6 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]' - -# 文本筛选:G 列以 a 开头 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]' -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母(如 `E`) | -| `--filter-type` | 是 | 筛选类型:`hiddenValue`、`number`、`text`、`color` | -| `--compare-type` | 否 | 比较运算符(如 `less`、`beginsWith`、`between`) | -| `--expected` | 是 | 筛选值 JSON 数组(如 `["6"]` 或 `["2","10"]`) | - -## 输出 - -JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。 diff --git a/skills/lark-sheets/references/lark-sheets-create-filter-view.md b/skills/lark-sheets/references/lark-sheets-create-filter-view.md deleted file mode 100644 index 4d60f34f9..000000000 --- a/skills/lark-sheets/references/lark-sheets-create-filter-view.md +++ /dev/null @@ -1,42 +0,0 @@ - -# sheets +create-filter-view(创建筛选视图) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +create-filter-view`。 - -在工作表中创建筛选视图,每个工作表最多 150 个筛选视图。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" - -# 指定名称 -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" --filter-view-name "我的筛选" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--range` | 是 | 筛选范围(如 `sheetId!A1:H14`) | -| `--filter-view-name` | 否 | 显示名称(最多 100 字符) | -| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID(不传则自动生成) | - -## 输出 - -JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。 - -## 参考 - -- [lark-sheets-list-filter-views](lark-sheets-list-filter-views.md) — 查询所有筛选视图 -- [lark-sheets-create-filter-view-condition](lark-sheets-create-filter-view-condition.md) — 添加筛选条件 diff --git a/skills/lark-sheets/references/lark-sheets-create-float-image.md b/skills/lark-sheets/references/lark-sheets-create-float-image.md deleted file mode 100644 index ae9597253..000000000 --- a/skills/lark-sheets/references/lark-sheets-create-float-image.md +++ /dev/null @@ -1,86 +0,0 @@ - -# 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-create.md b/skills/lark-sheets/references/lark-sheets-create.md deleted file mode 100644 index cba15ff1c..000000000 --- a/skills/lark-sheets/references/lark-sheets-create.md +++ /dev/null @@ -1,69 +0,0 @@ - -# sheets +create(创建表格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +create`。 - -特性: - -- 一步创建表格并返回 URL -- 可选 `--headers/--data` 在创建后自动写入到第一个工作表的 A1 开始 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -> [!IMPORTANT] -> 如果表格是**以应用身份(bot)创建**的,如 `lark-cli sheets +create --as bot` 在表格创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该表格的 `full_access`(可管理权限)**。 -> -> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: -> - `status = granted`:当前 CLI 用户已获得该表格的可管理权限 -> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 -> - `status = failed`:表格已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该表格 -> -> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 -> -> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 - -## 命令 - -```bash -# 最简单:只创建 -lark-cli sheets +create --title "仓库管理营收报表" - -# 创建并写入表头 + 初始数据 -lark-cli sheets +create --title "仓库管理营收报表" \ - --headers '["仓库","统计月份","入库金额","出库金额","销售收入","毛利率"]' \ - --data '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' - -# 创建到指定文件夹(folder_token) -lark-cli sheets +create --title "测试表" --folder-token "fldbc_xxx" - -# 仅预览参数(不发请求) -lark-cli sheets +create --title "测试表" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--title ` | 是 | 表格标题 | -| `--folder-token <token>` | 否 | 云空间文件夹 token(创建到指定目录) | -| `--headers <json>` | 否 | 一维数组 JSON(表头;写入到 A1) | -| `--data <json>` | 否 | 二维数组 JSON(初始数据;紧跟表头写入) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `spreadsheet_token` -- `title` -- `url` -- `permission_grant`(仅 `--as bot` 时返回) - -## 参考 - -- [lark-sheets-write](lark-sheets-write.md) — 后续覆盖写入 -- [lark-sheets-append](lark-sheets-append.md) — 后续追加写入 -- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-sheets/references/lark-sheets-delete-dimension.md b/skills/lark-sheets/references/lark-sheets-delete-dimension.md deleted file mode 100644 index 82a1afd5d..000000000 --- a/skills/lark-sheets/references/lark-sheets-delete-dimension.md +++ /dev/null @@ -1,53 +0,0 @@ - -# sheets +delete-dimension(删除行列) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +delete-dimension`。 - -删除指定范围的行或列,已有数据向上或向左移动。 - -> [!CAUTION] -> 这是**破坏性写入操作** —— 删除后数据不可恢复。执行前必须确认用户意图,建议先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 删除第 3-7 行(1-indexed,闭区间) -lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 - -# 删除第 5-8 列 -lark-cli sheets +delete-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension COLUMNS --start-index 5 --end-index 8 - -# 仅预览参数 -lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID | -| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | -| `--start-index <n>` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index <n>` | 是 | 结束位置(**1-indexed**,含) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `delCount`:实际删除的行/列数 -- `majorDimension`:`ROWS` 或 `COLUMNS` - -## 参考 - -- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 增加行列 -- [lark-sheets-insert-dimension](lark-sheets-insert-dimension.md) — 插入行列 -- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-delete-dropdown.md b/skills/lark-sheets/references/lark-sheets-delete-dropdown.md deleted file mode 100644 index 93ea43408..000000000 --- a/skills/lark-sheets/references/lark-sheets-delete-dropdown.md +++ /dev/null @@ -1,46 +0,0 @@ - -# sheets +delete-dropdown(删除下拉列表) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +delete-dropdown`。 - -删除指定范围的下拉列表配置。支持一次删除多个范围。 - -> [!CAUTION] -> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 删除单个范围 -lark-cli sheets +delete-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --ranges '["<sheetId>!A2:A100"]' - -# 删除多个范围 -lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]' -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A2:A100"]'`),单个范围最多 5000 格,单次最多 100 个范围 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `rangeResults[].range` — 对应的范围 -- `rangeResults[].success` — 是否成功 -- `rangeResults[].updatedCells` — 影响的单元格数量 - -## 参考 - -- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表 -- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表 -- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表 diff --git a/skills/lark-sheets/references/lark-sheets-delete-filter-view-condition.md b/skills/lark-sheets/references/lark-sheets-delete-filter-view-condition.md deleted file mode 100644 index 619c5ae9d..000000000 --- a/skills/lark-sheets/references/lark-sheets-delete-filter-view-condition.md +++ /dev/null @@ -1,26 +0,0 @@ - -# sheets +delete-filter-view-condition(删除筛选条件) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view-condition`。 - -> [!CAUTION] -> 这是**破坏性写入操作** —— 删除后不可恢复。 - -## 命令 - -```bash -lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母(如 `E`) | diff --git a/skills/lark-sheets/references/lark-sheets-delete-filter-view.md b/skills/lark-sheets/references/lark-sheets-delete-filter-view.md deleted file mode 100644 index fa6b706b0..000000000 --- a/skills/lark-sheets/references/lark-sheets-delete-filter-view.md +++ /dev/null @@ -1,25 +0,0 @@ - -# sheets +delete-filter-view(删除筛选视图) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view`。 - -> [!CAUTION] -> 这是**破坏性写入操作** —— 删除后不可恢复。执行前必须确认用户意图。 - -## 命令 - -```bash -lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | diff --git a/skills/lark-sheets/references/lark-sheets-delete-float-image.md b/skills/lark-sheets/references/lark-sheets-delete-float-image.md deleted file mode 100644 index a6d0af1b9..000000000 --- a/skills/lark-sheets/references/lark-sheets-delete-float-image.md +++ /dev/null @@ -1,37 +0,0 @@ - -# 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) diff --git a/skills/lark-sheets/references/lark-sheets-dropdown.md b/skills/lark-sheets/references/lark-sheets-dropdown.md new file mode 100644 index 000000000..2086b9621 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-dropdown.md @@ -0,0 +1,133 @@ +# Sheets Dropdown + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总下拉列表配置: + +- `+set-dropdown` +- `+update-dropdown` +- `+get-dropdown` +- `+delete-dropdown` + +> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。 + +<a id="set-dropdown"></a> +## `+set-dropdown` + +对应命令:`lark-cli sheets +set-dropdown` + +```bash +lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) | +| `--condition-values` | 是 | 下拉选项 JSON 数组 | +| `--multiple` | 否 | 是否多选 | +| `--highlight` | 否 | 是否着色 | +| `--colors` | 否 | 颜色 JSON 数组 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`code`、`msg` + +<a id="update-dropdown"></a> +## `+update-dropdown` + +对应命令:`lark-cli sheets +update-dropdown` + +```bash +lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" \ + --ranges '["<sheetId>!A1:A100"]' \ + --condition-values '["选项A", "选项B"]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--ranges` | 是 | 范围 JSON 数组 | +| `--condition-values` | 是 | 选项 JSON 数组 | +| `--multiple` | 否 | 是否多选 | +| `--highlight` | 否 | 是否着色 | +| `--colors` | 否 | 颜色 JSON 数组 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`spreadsheetToken`、`sheetId`、`dataValidation` + +<a id="get-dropdown"></a> +## `+get-dropdown` + +对应命令:`lark-cli sheets +get-dropdown` + +```bash +lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \ + --range "<sheetId>!A2:A100" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--range` | 是 | 查询范围 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `dataValidations[].conditionValues` +- `dataValidations[].ranges` +- `dataValidations[].options.multipleValues` +- `dataValidations[].options.highlightValidData` +- `dataValidations[].options.colorValueMap` + +<a id="delete-dropdown"></a> +## `+delete-dropdown` + +对应命令:`lark-cli sheets +delete-dropdown` + +```bash +lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \ + --ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--ranges` | 是 | 范围 JSON 数组 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `rangeResults[].range` +- `rangeResults[].success` +- `rangeResults[].updatedCells` + +## 典型流程 + +```bash +# 1. 配置下拉 +lark-cli sheets +set-dropdown --url "<url>" \ + --range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple + +# 2. 再写入 multipleValue +lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \ + --values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]' +``` + +## 参考 + +- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 diff --git a/skills/lark-sheets/references/lark-sheets-export.md b/skills/lark-sheets/references/lark-sheets-export.md deleted file mode 100644 index c68eaf9c8..000000000 --- a/skills/lark-sheets/references/lark-sheets-export.md +++ /dev/null @@ -1,51 +0,0 @@ - -# sheets +export(导出表格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +export`。 - -特性: - -- 创建导出任务并轮询完成(默认最多约 30 秒) -- 支持导出 `xlsx` 或 `csv` -- 若提供 `--output-path`,会直接下载并保存到本地;否则输出 `file_token` 供后续处理 - -## 命令 - -```bash -# 导出为 xlsx 并保存到本地 -lark-cli sheets +export --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --file-extension xlsx --output-path "./report.xlsx" - -# 导出为 csv(必须指定 sheet-id) -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" \ - --file-extension csv --sheet-id "<sheetId>" --output-path "./report.csv" - -# 不下载:只获取 file_token -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" --file-extension xlsx - -# 仅预览参数(不发请求) -lark-cli sheets +export --url "https://..." --file-extension xlsx --output-path "./report.xlsx" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--file-extension <ext>` | 是 | `xlsx` 或 `csv` | -| `--sheet-id <id>` | 否 | 工作表 ID(导出 `csv` 时必填;`xlsx` 可不填) | -| `--output-path <path>` | 否 | 本地保存路径;提供则自动下载保存 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -- 若提供 `--output-path`:输出 `file_path/file_name/file_size` -- 否则:输出 `file_token/file_name/file_size` - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) — 先获取 `sheet_id` -- [lark-shared](../../lark-shared/SKILL.md) \ No newline at end of file diff --git a/skills/lark-sheets/references/lark-sheets-filter-views.md b/skills/lark-sheets/references/lark-sheets-filter-views.md new file mode 100644 index 000000000..0535799b2 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-filter-views.md @@ -0,0 +1,193 @@ +# Sheets Filter Views + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总筛选视图和筛选条件: + +- `+create-filter-view` +- `+update-filter-view` +- `+list-filter-views` +- `+get-filter-view` +- `+delete-filter-view` +- `+create-filter-view-condition` +- `+update-filter-view-condition` +- `+list-filter-view-conditions` +- `+get-filter-view-condition` +- `+delete-filter-view-condition` + +<a id="create-filter-view"></a> +## `+create-filter-view` + +对应命令:`lark-cli sheets +create-filter-view` + +在工作表中创建筛选视图,每个工作表最多 150 个。 + +```bash +lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --range "<sheetId>!A1:H14" + +lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--range` | 是 | 筛选范围 | +| `--filter-view-name` | 否 | 显示名称 | +| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID | + +输出:`filter_view` + +<a id="update-filter-view"></a> +## `+update-filter-view` + +对应命令:`lark-cli sheets +update-filter-view` + +```bash +lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--filter-view-id` | 是 | 筛选视图 ID | +| `--range` | 否 | 新范围 | +| `--filter-view-name` | 否 | 新显示名称 | + +<a id="list-filter-views"></a> +## `+list-filter-views` + +对应命令:`lark-cli sheets +list-filter-views` + +```bash +lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>" +``` + +输出:`items[]`(`filter_view_id`、`filter_view_name`、`range`) + +<a id="get-filter-view"></a> +## `+get-filter-view` + +对应命令:`lark-cli sheets +get-filter-view` + +```bash +lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" +``` + +输出:`filter_view` + +<a id="delete-filter-view"></a> +## `+delete-filter-view` + +对应命令:`lark-cli sheets +delete-filter-view` + +```bash +lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--filter-view-id` | 是 | 筛选视图 ID | + +<a id="create-filter-view-condition"></a> +## `+create-filter-view-condition` + +对应命令:`lark-cli sheets +create-filter-view-condition` + +为筛选视图的指定列创建筛选条件。 + +```bash +# 数值筛选:E 列 < 6 +lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" \ + --condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]' + +# 文本筛选:G 列以 a 开头 +lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" \ + --condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--filter-view-id` | 是 | 筛选视图 ID | +| `--condition-id` | 是 | 列字母,如 `E` | +| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` | +| `--compare-type` | 否 | 比较运算符 | +| `--expected` | 是 | 筛选值 JSON 数组 | + +输出:`condition` + +<a id="update-filter-view-condition"></a> +## `+update-filter-view-condition` + +对应命令:`lark-cli sheets +update-filter-view-condition` + +```bash +lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \ + --filter-type "number" --compare-type "between" --expected '["2","10"]' +``` + +参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。 + +<a id="list-filter-view-conditions"></a> +## `+list-filter-view-conditions` + +对应命令:`lark-cli sheets +list-filter-view-conditions` + +```bash +lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" +``` + +输出:`items[]` + +<a id="get-filter-view-condition"></a> +## `+get-filter-view-condition` + +对应命令:`lark-cli sheets +get-filter-view-condition` + +```bash +lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" +``` + +输出:`condition` + +<a id="delete-filter-view-condition"></a> +## `+delete-filter-view-condition` + +对应命令:`lark-cli sheets +delete-filter-view-condition` + +```bash +lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" +``` + +## 参考 + +- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时 +- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find` diff --git a/skills/lark-sheets/references/lark-sheets-find.md b/skills/lark-sheets/references/lark-sheets-find.md deleted file mode 100644 index 18ce803b6..000000000 --- a/skills/lark-sheets/references/lark-sheets-find.md +++ /dev/null @@ -1,62 +0,0 @@ - -# sheets +find(查找单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -> **边界说明:** `sheets +find` 不是云空间搜索,只在一个已知 spreadsheet 内查找单元格内容。如果还不知道目标 spreadsheet 是哪一个,先用 [`lark-doc`](../../lark-doc/SKILL.md) 的 `docs +search` 定位文件;`docs +search` 的结果里会直接返回 `SHEET` 类型,再回到 `sheets +info` / `sheets +find`。 - -本 skill 对应 shortcut:`lark-cli sheets +find`。 - -特性: - -- `--sheet-id` 必填(建议先用 `sheets +info` 获取) -- `--range` 可写完整范围(如 `<sheetId>!A1:D200`) -- 若已传 `--sheet-id`,`--range` 也可直接写 `A1:D200` 或 `C2` -- 默认**区分大小写**;加 `--ignore-case` 可不区分大小写 -- 可选 `--search-by-regex` 按正则匹配 - -## 命令 - -```bash -# 在指定范围查找(默认区分大小写) -lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "张三" --range "A1:H200" - -# 不区分大小写 -lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "仓库管理营收报表" --range "H1:H500" --ignore-case - -# 正则查找 -lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "仓库管理营收报表" --range "H1:H500" --search-by-regex - -# 仅预览参数(不发请求) -lark-cli sheets +find --url "https://..." --sheet-id "<sheetId>" --find "xxx" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID(可通过 `+info` 获取) | -| `--find <text>` | 是 | 查找内容(字符串或正则) | -| `--range <range>` | 否 | 范围(如 `<sheetId>!A1:D200`,或 `A1:D200` / `C2` 配合 `--sheet-id`);不填则搜索整个工作表 | -| `--ignore-case` | 否 | 不区分大小写(默认区分) | -| `--match-entire-cell` | 否 | 完全匹配单元格 | -| `--search-by-regex` | 否 | 使用正则 | -| `--include-formulas` | 否 | 搜索公式 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `matched_cells` -- `matched_formula_cells` -- `rows_count` - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) -- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-sheets/references/lark-sheets-float-images.md b/skills/lark-sheets/references/lark-sheets-float-images.md new file mode 100644 index 000000000..7c5f3f1b4 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-float-images.md @@ -0,0 +1,125 @@ +# Sheets Float Images + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总浮动图片相关能力: + +- `+media-upload` +- `+create-float-image` +- `+update-float-image` +- `+get-float-image` +- `+list-float-images` +- `+delete-float-image` + +<a id="media-upload"></a> +## `+media-upload` + +对应命令:`lark-cli sheets +media-upload` + +把本地图片上传到指定电子表格的素材空间,返回 `file_token`,供 `+create-float-image` 使用。 + +```bash +lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --file ./image.png +``` + +说明: + +- 内部调用 `drive/v1/medias/upload_all` +- `>20MB` 自动分片上传 +- `--file` 只能是当前工作目录下的相对路径 + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--file` | 是 | 本地图片路径,必须是相对路径 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`file_token`、`file_name`、`size`、`spreadsheet_token` + +<a id="create-float-image"></a> +## `+create-float-image` + +对应命令:`lark-cli sheets +create-float-image` + +```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 +``` + +关键规则: + +- `--float-image-token` 必须来自 `+media-upload` +- `--range` 必须锚定单个单元格 +- `width` / `height` 必须 `>=20` +- `offset-x` / `offset-y` 必须 `>=0` + +输出:`float_image` + +<a id="update-float-image"></a> +## `+update-float-image` + +对应命令:`lark-cli sheets +update-float-image` + +```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 +``` + +至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y` + +输出:更新后的 `float_image` + +<a id="get-float-image"></a> +## `+get-float-image` + +对应命令:`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" +``` + +输出:`float_image` + +<a id="list-float-images"></a> +## `+list-float-images` + +对应命令:`lark-cli sheets +list-float-images` + +```bash +lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "<sheetId>" +``` + +输出:`items[]` + +<a id="delete-float-image"></a> +## `+delete-float-image` + +对应命令:`lark-cli sheets +delete-float-image` + +```bash +lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "<sheetId>" --float-image-id "fi12345678" +``` + +输出:`code`、`msg` + +## 读取图片内容 + +上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调: + +```bash +lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png +``` + +## 参考 + +- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片 +- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` diff --git a/skills/lark-sheets/references/lark-sheets-formula.md b/skills/lark-sheets/references/lark-sheets-formula.md index 48072a81c..a00460924 100644 --- a/skills/lark-sheets/references/lark-sheets-formula.md +++ b/skills/lark-sheets/references/lark-sheets-formula.md @@ -1,7 +1,6 @@ - # 飞书表格公式规则 -> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认"投影"(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。 +> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认“投影”(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。 ## 写入方式 @@ -45,7 +44,7 @@ ## 隐式逐项求值 → MAP/LAMBDA -Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 MAP 显式遍历: +Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 `MAP` 显式遍历: ```text # Excel @@ -55,11 +54,11 @@ Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐 =SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r))))) ``` -同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 MAP。 +同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 `MAP`。 ## 多维结果降维 -飞书公式结果只能是二维,不能返回"区域的列表"。合并多个区域时: +飞书公式结果只能是二维,不能返回“区域的列表”。合并多个区域时: | 需求 | 写法 | |------|------| diff --git a/skills/lark-sheets/references/lark-sheets-get-dropdown.md b/skills/lark-sheets/references/lark-sheets-get-dropdown.md deleted file mode 100644 index ab7874e17..000000000 --- a/skills/lark-sheets/references/lark-sheets-get-dropdown.md +++ /dev/null @@ -1,43 +0,0 @@ - -# sheets +get-dropdown(查询下拉列表) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +get-dropdown`。 - -查询指定范围内已配置的下拉列表设置,包括选项值、是否多选、颜色映射等。 - -## 命令 - -```bash -lark-cli sheets +get-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "<sheetId>!A2:A100" - -lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A2:A100" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `dataValidations[].conditionValues` — 下拉选项列表 -- `dataValidations[].ranges` — 应用范围 -- `dataValidations[].options.multipleValues` — 是否多选 -- `dataValidations[].options.highlightValidData` — 是否着色 -- `dataValidations[].options.colorValueMap` — 选项颜色映射 - -## 参考 - -- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表 -- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表 -- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表 diff --git a/skills/lark-sheets/references/lark-sheets-get-filter-view-condition.md b/skills/lark-sheets/references/lark-sheets-get-filter-view-condition.md deleted file mode 100644 index b9d06cae5..000000000 --- a/skills/lark-sheets/references/lark-sheets-get-filter-view-condition.md +++ /dev/null @@ -1,27 +0,0 @@ - -# sheets +get-filter-view-condition(获取筛选条件) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +get-filter-view-condition`。 - -## 命令 - -```bash -lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母(如 `E`) | - -## 输出 - -JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。 diff --git a/skills/lark-sheets/references/lark-sheets-get-filter-view.md b/skills/lark-sheets/references/lark-sheets-get-filter-view.md deleted file mode 100644 index cf5ff0216..000000000 --- a/skills/lark-sheets/references/lark-sheets-get-filter-view.md +++ /dev/null @@ -1,26 +0,0 @@ - -# sheets +get-filter-view(获取筛选视图) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +get-filter-view`。 - -## 命令 - -```bash -lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | - -## 输出 - -JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。 diff --git a/skills/lark-sheets/references/lark-sheets-get-float-image.md b/skills/lark-sheets/references/lark-sheets-get-float-image.md deleted file mode 100644 index 9b2619fa3..000000000 --- a/skills/lark-sheets/references/lark-sheets-get-float-image.md +++ /dev/null @@ -1,44 +0,0 @@ - -# 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) diff --git a/skills/lark-sheets/references/lark-sheets-info.md b/skills/lark-sheets/references/lark-sheets-info.md deleted file mode 100644 index 7167a154e..000000000 --- a/skills/lark-sheets/references/lark-sheets-info.md +++ /dev/null @@ -1,43 +0,0 @@ - -# sheets +info(查看表格/工作表信息) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +info`。 - -用于: - -- 从表格 URL / token 获取 `spreadsheet_token` -- 列出工作表(`sheet_id`、标题、行列数等),便于后续 `+read/+write/+find/+export` 使用 - -## 命令 - -```bash -# 传 URL(支持用户粘贴时带空格/引号/反引号;支持 wiki URL) -lark-cli sheets +info --url "https://example.larksuite.com/sheets/shtxxxxxxxx" - -# 传 spreadsheet_token -lark-cli sheets +info --spreadsheet-token "shtxxxxxxxx" - -# 仅预览请求参数(不发请求) -lark-cli sheets +info --url "https://..." --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `spreadsheet_token`:后续命令复用 -- `sheets[]`:每个工作表的 `sheet_id`、`title`、`row_count`、`column_count` 等 - -## 参考 - -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 \ No newline at end of file diff --git a/skills/lark-sheets/references/lark-sheets-insert-dimension.md b/skills/lark-sheets/references/lark-sheets-insert-dimension.md deleted file mode 100644 index e6cf08b4c..000000000 --- a/skills/lark-sheets/references/lark-sheets-insert-dimension.md +++ /dev/null @@ -1,51 +0,0 @@ - -# sheets +insert-dimension(插入行列) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +insert-dimension`。 - -在指定位置插入空行或空列,已有数据向下或向右移动。支持继承相邻行/列样式。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 在第 3 行前插入 4 行空行(0-indexed,插入位置 3~7,不含 7) -lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 - -# 插入列,并继承前方列的样式 -lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension COLUMNS --start-index 2 --end-index 4 \ - --inherit-style BEFORE - -# 仅预览参数 -lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 0 --end-index 2 --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID | -| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | -| `--start-index <n>` | 是 | 起始位置(0-indexed) | -| `--end-index <n>` | 是 | 结束位置(0-indexed,不包含;插入数量 = end - start) | -| `--inherit-style <BEFORE\|AFTER>` | 否 | 样式继承方向:`BEFORE` 继承前方、`AFTER` 继承后方;不传则为空白样式 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON(成功时 `data` 为空对象 `{}`)。 - -## 参考 - -- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 在末尾追加行列 -- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-list-filter-view-conditions.md b/skills/lark-sheets/references/lark-sheets-list-filter-view-conditions.md deleted file mode 100644 index 569c514cc..000000000 --- a/skills/lark-sheets/references/lark-sheets-list-filter-view-conditions.md +++ /dev/null @@ -1,28 +0,0 @@ - -# sheets +list-filter-view-conditions(查询筛选条件) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +list-filter-view-conditions`。 - -查询筛选视图的所有筛选条件。 - -## 命令 - -```bash -lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | - -## 输出 - -JSON,包含 `items[]`(condition_id, filter_type, compare_type, expected)。 diff --git a/skills/lark-sheets/references/lark-sheets-list-filter-views.md b/skills/lark-sheets/references/lark-sheets-list-filter-views.md deleted file mode 100644 index 3251fd687..000000000 --- a/skills/lark-sheets/references/lark-sheets-list-filter-views.md +++ /dev/null @@ -1,26 +0,0 @@ - -# sheets +list-filter-views(查询筛选视图) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +list-filter-views`。 - -查询工作表中的所有筛选视图,返回视图 ID、名称和范围。 - -## 命令 - -```bash -lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | - -## 输出 - -JSON,包含 `items[]`(filter_view_id, filter_view_name, range)。 diff --git a/skills/lark-sheets/references/lark-sheets-list-float-images.md b/skills/lark-sheets/references/lark-sheets-list-float-images.md deleted file mode 100644 index e9fad4606..000000000 --- a/skills/lark-sheets/references/lark-sheets-list-float-images.md +++ /dev/null @@ -1,43 +0,0 @@ - -# 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) diff --git a/skills/lark-sheets/references/lark-sheets-media-upload.md b/skills/lark-sheets/references/lark-sheets-media-upload.md deleted file mode 100644 index e530fd4b2..000000000 --- a/skills/lark-sheets/references/lark-sheets-media-upload.md +++ /dev/null @@ -1,74 +0,0 @@ - -# 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) — 读取浮动图片元数据 diff --git a/skills/lark-sheets/references/lark-sheets-merge-cells.md b/skills/lark-sheets/references/lark-sheets-merge-cells.md deleted file mode 100644 index 92bf6eb4a..000000000 --- a/skills/lark-sheets/references/lark-sheets-merge-cells.md +++ /dev/null @@ -1,47 +0,0 @@ - -# sheets +merge-cells(合并单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +merge-cells`。 - -合并指定范围的单元格,支持全合并、按行合并、按列合并三种模式。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 全合并 A1:B2 -lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" --merge-type MERGE_ALL - -# 按行合并,配合 --sheet-id -lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "A1:D4" --merge-type MERGE_ROWS - -# 仅预览 -lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" --merge-type MERGE_ALL --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id` 使用 `A1:B2`) | -| `--sheet-id <id>` | 否 | 工作表 ID(用于相对范围) | -| `--merge-type <type>` | 是 | 合并方式:`MERGE_ALL`(全合并)、`MERGE_ROWS`(按行)、`MERGE_COLUMNS`(按列) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含 `spreadsheetToken`。 - -## 参考 - -- [lark-sheets-unmerge-cells](lark-sheets-unmerge-cells.md) — 拆分单元格 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-move-dimension.md b/skills/lark-sheets/references/lark-sheets-move-dimension.md deleted file mode 100644 index 3398c7882..000000000 --- a/skills/lark-sheets/references/lark-sheets-move-dimension.md +++ /dev/null @@ -1,52 +0,0 @@ - -# sheets +move-dimension(移动行列) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +move-dimension`。 - -将指定范围的行/列移动到目标位置。被移动到目标位置后,原本在目标位置的行/列会对应右移或下移。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 将第 0-1 行移动到第 4 行位置 -lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS \ - --start-index 0 --end-index 1 --destination-index 4 - -# 将第 2 列移动到第 0 列位置 -lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension COLUMNS \ - --start-index 2 --end-index 2 --destination-index 0 - -# 仅预览参数 -lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS \ - --start-index 0 --end-index 1 --destination-index 4 --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID | -| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | -| `--start-index <n>` | 是 | 源起始位置(0-indexed) | -| `--end-index <n>` | 是 | 源结束位置(0-indexed,含) | -| `--destination-index <n>` | 是 | 目标位置(0-indexed) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON(成功时 `data` 为空对象 `{}`)。 - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-read.md b/skills/lark-sheets/references/lark-sheets-read.md deleted file mode 100644 index 85ad7fd31..000000000 --- a/skills/lark-sheets/references/lark-sheets-read.md +++ /dev/null @@ -1,61 +0,0 @@ - -# sheets +read(读取单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +read`。 - -内置能力: - -- 支持 `--url` / `--spreadsheet-token` 二选一(URL 支持 wiki;支持粘贴时带空格/引号/反引号) -- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2` -- 将单元格富文本 segment 数组拍平成纯文本,减少输出冗余 -- 默认最多返回 200 行(超出会 `truncated=true`) - -## 命令 - -```bash -# 读取指定范围(推荐) -lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "<sheetId>!A1:H20" - -# 配合 --sheet-id,可直接写相对范围或单个单元格 -lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "C2" - -# 仅指定工作表(不含 A1:D10),读取整个工作表(仍会做 200 行截断) -lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" --range "<sheetId>" - -# 不指定 range:读取 --sheet-id 对应工作表;再不指定则读取第一个工作表 -lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>" - -# 控制值渲染方式 -lark-cli sheets +read --url "https://..." --range "<sheetId>!A1:D10" --value-render-option Formula - -# 仅预览参数(不发请求) -lark-cli sheets +read --url "https://..." --range "<sheetId>!A1:D10" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 否 | 读取范围:`<sheetId>!A1:D10`、`A1:D10` / `C2`(需配合 `--sheet-id`),或 `<sheetId>` | -| `--sheet-id <id>` | 否 | 工作表 ID(不提供 `--range` 时生效) | -| `--value-render-option <opt>` | 否 | `ToString`(默认)/ `FormattedValue` / `Formula` / `UnformattedValue` | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `range`:服务端实际读取的范围 -- `values`:二维数组(已做富文本拍平) -- `truncated/total_rows`:当行数超过 200 时出现 - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) — 先获取 `sheet_id` -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-replace.md b/skills/lark-sheets/references/lark-sheets-replace.md deleted file mode 100644 index 46b4a4d55..000000000 --- a/skills/lark-sheets/references/lark-sheets-replace.md +++ /dev/null @@ -1,62 +0,0 @@ - -# sheets +replace(替换单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +replace`。 - -在指定范围内查找并替换单元格内容,支持正则、大小写敏感、全单元格匹配等选项。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 简单替换 -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "hello" --replacement "world" - -# 指定范围 + 大小写敏感 -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "A1:C5" \ - --find "Hello" --replacement "World" --match-case - -# 正则替换 -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "\\d{4}-\\d{2}-\\d{2}" \ - --replacement "DATE" --search-by-regex - -# 仅预览 -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --find "old" --replacement "new" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID | -| `--find <text>` | 是 | 搜索文本(启用 `--search-by-regex` 时为正则表达式) | -| `--replacement <text>` | 是 | 替换文本 | -| `--range <range>` | 否 | 搜索范围(不传则搜索整个工作表) | -| `--match-case` | 否 | 区分大小写 | -| `--match-entire-cell` | 否 | 匹配整个单元格 | -| `--search-by-regex` | 否 | 使用正则表达式搜索 | -| `--include-formulas` | 否 | 在公式中搜索 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含 `replace_result`: - -- `matched_cells`:匹配的非公式单元格列表 -- `matched_formula_cells`:匹配的公式单元格列表 -- `rows_count`:包含匹配的行数 - -## 参考 - -- [lark-sheets-find](lark-sheets-find.md) — 查找单元格(只查不改) -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-row-column-management.md b/skills/lark-sheets/references/lark-sheets-row-column-management.md new file mode 100644 index 000000000..1c4d553a6 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-row-column-management.md @@ -0,0 +1,151 @@ +# Sheets Row and Column Management + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总行列结构操作: + +- `+add-dimension` +- `+insert-dimension` +- `+update-dimension` +- `+move-dimension` +- `+delete-dimension` + +<a id="add-dimension"></a> +## `+add-dimension` + +对应命令:`lark-cli sheets +add-dimension` + +在工作表末尾追加空行或空列,不影响已有数据。 + +```bash +lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --dimension ROWS --length 10 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | +| `--length` | 是 | 追加数量(1-5000) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`addCount`、`majorDimension` + +<a id="insert-dimension"></a> +## `+insert-dimension` + +对应命令:`lark-cli sheets +insert-dimension` + +在指定位置插入空行或空列,已有数据向下或向右移动。 + +```bash +lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | +| `--start-index` | 是 | 起始位置(0-indexed) | +| `--end-index` | 是 | 结束位置(0-indexed,不含) | +| `--inherit-style` | 否 | `BEFORE` 或 `AFTER` | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:成功时 `data` 为空对象 `{}` + +<a id="update-dimension"></a> +## `+update-dimension` + +对应命令:`lark-cli sheets +update-dimension` + +更新指定范围行/列的显隐状态和行高/列宽。 + +```bash +lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \ + --visible=false +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | +| `--start-index` | 是 | 起始位置(**1-indexed**,含) | +| `--end-index` | 是 | 结束位置(**1-indexed**,含) | +| `--visible` | 否 | `--visible=true` 或 `--visible=false` | +| `--fixed-size` | 否 | 行高或列宽(像素) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:成功时 `data` 为空对象 `{}` + +<a id="move-dimension"></a> +## `+move-dimension` + +对应命令:`lark-cli sheets +move-dimension` + +将指定范围的行/列移动到目标位置。 + +```bash +lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --dimension ROWS \ + --start-index 0 --end-index 1 --destination-index 4 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | +| `--start-index` | 是 | 源起始位置(0-indexed) | +| `--end-index` | 是 | 源结束位置(0-indexed,含) | +| `--destination-index` | 是 | 目标位置(0-indexed) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:成功时 `data` 为空对象 `{}` + +<a id="delete-dimension"></a> +## `+delete-dimension` + +对应命令:`lark-cli sheets +delete-dimension` + +删除指定范围的行或列。 + +```bash +lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 工作表 ID | +| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | +| `--start-index` | 是 | 起始位置(**1-indexed**,含) | +| `--end-index` | 是 | 结束位置(**1-indexed**,含) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出:`delCount`、`majorDimension` + +## 参考 + +- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 查看当前工作表信息 +- [cell-style-and-merge](lark-sheets-cell-style-and-merge.md) — 调整样式或合并单元格 diff --git a/skills/lark-sheets/references/lark-sheets-set-dropdown.md b/skills/lark-sheets/references/lark-sheets-set-dropdown.md deleted file mode 100644 index 18ab70738..000000000 --- a/skills/lark-sheets/references/lark-sheets-set-dropdown.md +++ /dev/null @@ -1,62 +0,0 @@ - -# sheets +set-dropdown(设置下拉列表) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +set-dropdown`。 - -为指定范围的单元格配置下拉列表选项。**这是使用 `multipleValue` 格式写入数据的前置步骤**——未配置下拉选项的单元格,`multipleValue` 写入会变成纯文本。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 基础:设置单选下拉 -lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' - -# 多选 + 颜色高亮 -lark-cli sheets +set-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' \ - --multiple --highlight --colors '["#1FB6C1", "#F006C2", "#FB16C3"]' - -# 仅预览参数(不发请求) -lark-cli sheets +set-dropdown --url "https://..." --range "..." --condition-values '...' --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`),单次最多 5000 行 x 100 列 | -| `--condition-values` | 是 | 下拉选项,JSON 数组(如 `'["选项1","选项2"]'`),最多 500 个,每个 ≤100 字符,不能包含逗号 | -| `--multiple` | 否 | 是否多选,默认 false | -| `--highlight` | 否 | 是否着色,默认 false | -| `--colors` | 否 | RGB 十六进制颜色 JSON 数组,需与 `--condition-values` 一一对应(`--highlight` 时必填) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含 `code`(0=成功)和 `msg`。 - -## 典型流程 - -```bash -# 1. 先配置下拉选项 -lark-cli sheets +set-dropdown --url "<url>" \ - --range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple - -# 2. 再用 multipleValue 写入 -lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \ - --values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]' -``` - -## 参考 - -- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表 -- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表 -- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表 diff --git a/skills/lark-sheets/references/lark-sheets-set-style.md b/skills/lark-sheets/references/lark-sheets-set-style.md deleted file mode 100644 index 4fb34710d..000000000 --- a/skills/lark-sheets/references/lark-sheets-set-style.md +++ /dev/null @@ -1,71 +0,0 @@ - -# sheets +set-style(设置单元格样式) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +set-style`。 - -对指定范围的单元格设置样式(字体、颜色、对齐、边框等)。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 设置加粗 + 红色背景 -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:C3" \ - --style '{"font":{"bold":true},"backColor":"#ff0000"}' - -# 配合 --sheet-id + 居中对齐 -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "A1:D1" \ - --style '{"hAlign":1,"vAlign":1,"font":{"bold":true,"font_size":"12pt/1.5"}}' - -# 清除格式 -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:Z100" --style '{"clean":true}' - -# 仅预览 -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" --style '{"foreColor":"#0000ff"}' --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id`) | -| `--sheet-id <id>` | 否 | 工作表 ID(用于相对范围) | -| `--style <json>` | 是 | 样式 JSON 对象 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -### style JSON 字段 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `font.bold` | bool | 加粗 | -| `font.italic` | bool | 斜体 | -| `font.font_size` | string | 字号,如 `"12pt/1.5"` | -| `font.clean` | bool | 清除字体格式 | -| `textDecoration` | int | 0=无, 1=下划线, 2=删除线, 3=两者 | -| `formatter` | string | 数字格式 | -| `hAlign` | int | 水平对齐:0=左, 1=居中, 2=右 | -| `vAlign` | int | 垂直对齐:0=上, 1=居中, 2=下 | -| `foreColor` | string | 字体颜色(hex,如 `"#000000"`) | -| `backColor` | string | 背景色(hex) | -| `borderType` | string | 边框:FULL_BORDER, OUTER_BORDER, INNER_BORDER, NO_BORDER 等 | -| `borderColor` | string | 边框颜色(hex) | -| `clean` | bool | 清除所有格式 | - -## 输出 - -JSON,包含 `updates`(updatedRange, updatedRows, updatedColumns, updatedCells, revision)。 - -## 参考 - -- [lark-sheets-batch-set-style](lark-sheets-batch-set-style.md) — 批量设置样式 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-management.md b/skills/lark-sheets/references/lark-sheets-sheet-management.md new file mode 100644 index 000000000..089059c91 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-sheet-management.md @@ -0,0 +1,164 @@ +# Sheets Sheet Management + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总工作表级操作: + +- `+create-sheet` +- `+copy-sheet` +- `+delete-sheet` +- `+update-sheet` + +其中 `+create-sheet` / `+copy-sheet` / `+delete-sheet` 底层封装官方“操作工作表(operate-sheets)”接口;`+update-sheet` 封装“更新工作表属性”接口。 + +<a id="create-sheet"></a> +## `+create-sheet` + +对应命令:`lark-cli sheets +create-sheet` + +```bash +# 在表格末尾或服务端默认位置创建工作表 +lark-cli sheets +create-sheet --spreadsheet-token "shtxxxxxxxx" \ + --title "明细" + +# 指定插入位置(0-based) +lark-cli sheets +create-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --title "汇总" --index 0 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--title` | 否 | 工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | +| `--index` | 否 | 工作表位置(从 0 开始) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `spreadsheet_token` +- `sheet.sheet_id` +- `sheet.title` +- `sheet.index` + +<a id="copy-sheet"></a> +## `+copy-sheet` + +对应命令:`lark-cli sheets +copy-sheet` + +```bash +# 按默认位置复制 +lark-cli sheets +copy-sheet --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" + +# 指定副本名称和位置 +lark-cli sheets +copy-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "<sheetId>" --title "销售副本" --index 2 +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 源工作表 ID | +| `--title` | 否 | 新工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | +| `--index` | 否 | 新工作表位置(从 0 开始) | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +说明: + +- 传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引 + +输出: + +- `spreadsheet_token` +- `sheet.sheet_id` +- `sheet.title` +- `sheet.index` + +<a id="delete-sheet"></a> +## `+delete-sheet` + +对应命令:`lark-cli sheets +delete-sheet` + +> [!CAUTION] +> 这是**高风险删除操作**。CLI 会要求显式确认;可以先用 `--dry-run` 预览。 + +```bash +lark-cli sheets +delete-sheet --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 要删除的工作表 ID | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `deleted` +- `spreadsheet_token` +- `sheet_id` + +<a id="update-sheet"></a> +## `+update-sheet` + +对应命令:`lark-cli sheets +update-sheet` + +用于更新工作表标题、位置、隐藏状态、冻结行列和保护设置。 + +```bash +# 改名 + 调整冻结 +lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --title "汇总表" --frozen-row-count 2 --frozen-col-count 1 + +# 隐藏工作表 +lark-cli sheets +update-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "<sheetId>" --hidden=true + +# 开启保护并授权额外编辑人 +lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "<sheetId>" --lock LOCK --lock-info "仅财务维护" \ + --user-id-type open_id --user-ids '["ou_xxx","ou_yyy"]' +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 表格 token | +| `--sheet-id` | 是 | 要更新的工作表 ID | +| `--title` | 否 | 新标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | +| `--index` | 否 | 新位置(从 0 开始) | +| `--hidden` | 否 | `--hidden=true` 隐藏,`--hidden=false` 取消隐藏 | +| `--frozen-row-count` | 否 | 冻结行数,`0` 表示取消冻结 | +| `--frozen-col-count` | 否 | 冻结列数,`0` 表示取消冻结 | +| `--lock` | 否 | 保护模式:`LOCK` / `UNLOCK` | +| `--lock-info` | 否 | 保护备注;要求 `--lock LOCK` | +| `--user-id-type` | 否 | `--user-ids` 的 ID 类型:`open_id` / `union_id` / `lark_id` / `user_id` | +| `--user-ids` | 否 | 额外可编辑用户 ID 的 JSON 数组;要求 `--lock LOCK` | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `spreadsheet_token` +- `sheet.sheet_id` +- `sheet.title` +- `sheet.hidden` +- `sheet.grid_properties.frozen_row_count` +- `sheet.grid_properties.frozen_column_count` +- `sheet.protect` + +## 参考 + +- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` +- [row-column-management](lark-sheets-row-column-management.md) — 需要改行列结构时用这组命令 diff --git a/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md b/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md new file mode 100644 index 000000000..82adc64d5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md @@ -0,0 +1,134 @@ +# Sheets Spreadsheet Management + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +这份 reference 汇总电子表格对象级操作: + +- `+create`:创建电子表格 +- `+info`:查看电子表格和工作表信息 +- `+export`:导出电子表格 + +<a id="create"></a> +## `+create` + +对应命令:`lark-cli sheets +create` + +特性: + +- 一步创建表格并返回 URL +- 可选 `--headers/--data` 在创建后自动写入第一个工作表的 A1 开始 +- `--as bot` 创建成功后,CLI 会尝试为当前 CLI 用户自动授予 `full_access` + +```bash +# 只创建表格 +lark-cli sheets +create --title "仓库管理营收报表" + +# 创建并写入表头 + 初始数据 +lark-cli sheets +create --title "仓库管理营收报表" \ + --headers '["仓库","统计月份","入库金额","出库金额","销售收入","毛利率"]' \ + --data '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' + +# 创建到指定文件夹 +lark-cli sheets +create --title "测试表" --folder-token "fldbc_xxx" + +# 仅预览请求 +lark-cli sheets +create --title "测试表" --dry-run +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--title` | 是 | 表格标题 | +| `--folder-token` | 否 | 创建到指定文件夹 | +| `--headers` | 否 | 一维数组 JSON,作为表头写入 | +| `--data` | 否 | 二维数组 JSON,作为初始数据写入 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `spreadsheet_token` +- `title` +- `url` +- `permission_grant`(仅 `--as bot` 时返回) + +<a id="info"></a> +## `+info` + +对应命令:`lark-cli sheets +info` + +用于: + +- 从表格 URL / token 获取 `spreadsheet_token` +- 列出工作表的 `sheet_id`、标题、行列数、冻结状态等信息 + +```bash +# 传 URL(支持 wiki URL) +lark-cli sheets +info --url "https://example.larksuite.com/sheets/shtxxxxxxxx" + +# 传 spreadsheet_token +lark-cli sheets +info --spreadsheet-token "shtxxxxxxxx" + +# 仅预览请求 +lark-cli sheets +info --url "https://..." --dry-run +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | +| `--spreadsheet-token` | 否 | 电子表格 token | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- `spreadsheet.spreadsheet.token` +- `spreadsheet.spreadsheet.url` +- `sheets.sheets[]` + +<a id="export"></a> +## `+export` + +对应命令:`lark-cli sheets +export` + +特性: + +- 创建导出任务并轮询完成 +- 支持导出 `xlsx` 或 `csv` +- 提供 `--output-path` 时自动下载,否则只返回 `file_token` + +```bash +# 导出 xlsx 并保存到本地 +lark-cli sheets +export --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --file-extension xlsx --output-path "./report.xlsx" + +# 导出 csv(必须指定 sheet-id) +lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" \ + --file-extension csv --sheet-id "<sheetId>" --output-path "./report.csv" + +# 只返回导出文件 token +lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" --file-extension xlsx +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 否 | 电子表格 token | +| `--file-extension` | 是 | `xlsx` 或 `csv` | +| `--sheet-id` | 否 | 导出 `csv` 时必填 | +| `--output-path` | 否 | 保存到本地的路径 | +| `--dry-run` | 否 | 仅打印请求,不执行 | + +输出: + +- 提供 `--output-path`:`saved_path`、`file_name`、`file_size` +- 不提供 `--output-path`:`file_token`、`file_name`、`file_size` + +## 参考 + +- [sheet-management](lark-sheets-sheet-management.md) — 管理工作表 +- [cell-data](lark-sheets-cell-data.md) — 读写单元格数据 +- [float-images](lark-sheets-float-images.md) — 上传和管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-unmerge-cells.md b/skills/lark-sheets/references/lark-sheets-unmerge-cells.md deleted file mode 100644 index 60cb843f1..000000000 --- a/skills/lark-sheets/references/lark-sheets-unmerge-cells.md +++ /dev/null @@ -1,46 +0,0 @@ - -# sheets +unmerge-cells(拆分单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +unmerge-cells`。 - -拆分指定范围内的合并单元格。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 拆分 A1:B2 范围的合并单元格 -lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" - -# 配合 --sheet-id -lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "A1:B2" - -# 仅预览 -lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 是 | 单元格范围(`<sheetId>!A1:B2`,或配合 `--sheet-id` 使用 `A1:B2`) | -| `--sheet-id <id>` | 否 | 工作表 ID(用于相对范围) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含 `spreadsheetToken`。 - -## 参考 - -- [lark-sheets-merge-cells](lark-sheets-merge-cells.md) — 合并单元格 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-update-dimension.md b/skills/lark-sheets/references/lark-sheets-update-dimension.md deleted file mode 100644 index d525fb86d..000000000 --- a/skills/lark-sheets/references/lark-sheets-update-dimension.md +++ /dev/null @@ -1,60 +0,0 @@ - -# sheets +update-dimension(更新行列属性) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +update-dimension`。 - -更新指定范围行/列的属性,支持设置显隐状态和行高/列宽。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 隐藏第 1-3 行 -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \ - --visible=false - -# 设置第 1-5 列列宽为 120px -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension COLUMNS --start-index 1 --end-index 5 \ - --fixed-size 120 - -# 同时设置显示 + 行高 -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 10 \ - --visible=true --fixed-size 50 - -# 仅预览参数 -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \ - --visible=true --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--sheet-id <id>` | 是 | 工作表 ID | -| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | -| `--start-index <n>` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index <n>` | 是 | 结束位置(**1-indexed**,含) | -| `--visible <true\|false>` | 否 | `true` 显示 / `false` 隐藏(须与 `--fixed-size` 至少传一个) | -| `--fixed-size <px>` | 否 | 行高或列宽(像素)(须与 `--visible` 至少传一个) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -> **注意**:`--visible` 是 bool flag,传值时使用 `--visible=true` 或 `--visible=false` 格式。 - -## 输出 - -JSON(成功时 `data` 为空对象 `{}`)。 - -## 参考 - -- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列属性 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-update-dropdown.md b/skills/lark-sheets/references/lark-sheets-update-dropdown.md deleted file mode 100644 index f83b6091f..000000000 --- a/skills/lark-sheets/references/lark-sheets-update-dropdown.md +++ /dev/null @@ -1,51 +0,0 @@ - -# sheets +update-dropdown(更新下拉列表) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +update-dropdown`。 - -更新已有下拉列表的选项、颜色等配置。可同时更新多个范围。 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -lark-cli sheets +update-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "<sheetId>" \ - --ranges '["<sheetId>!A1:A100", "<sheetId>!C1:C100"]' \ - --condition-values '["新选项1", "新选项2", "新选项3"]' - -# 更新为多选 + 着色 -lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" \ - --ranges '["<sheetId>!A1:A100"]' \ - --condition-values '["选项A", "选项B"]' \ - --multiple --highlight --colors '["#1FB6C1", "#F006C2"]' -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A1:A100"]'`) | -| `--condition-values` | 是 | 新的下拉选项,JSON 数组 | -| `--multiple` | 否 | 是否多选,默认 false | -| `--highlight` | 否 | 是否着色,默认 false | -| `--colors` | 否 | RGB 颜色 JSON 数组,需与 `--condition-values` 一一对应 | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含 `spreadsheetToken`、`sheetId`、`dataValidation`(选项值和颜色映射)。 - -## 参考 - -- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表 -- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表 -- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表 diff --git a/skills/lark-sheets/references/lark-sheets-update-filter-view-condition.md b/skills/lark-sheets/references/lark-sheets-update-filter-view-condition.md deleted file mode 100644 index e874e6e9b..000000000 --- a/skills/lark-sheets/references/lark-sheets-update-filter-view-condition.md +++ /dev/null @@ -1,27 +0,0 @@ - -# sheets +update-filter-view-condition(更新筛选条件) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +update-filter-view-condition`。 - -## 命令 - -```bash -lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \ - --filter-type "number" --compare-type "between" --expected '["2","10"]' -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母(如 `E`) | -| `--filter-type` | 否 | 筛选类型 | -| `--compare-type` | 否 | 比较运算符 | -| `--expected` | 否 | 筛选值 JSON 数组 | diff --git a/skills/lark-sheets/references/lark-sheets-update-filter-view.md b/skills/lark-sheets/references/lark-sheets-update-filter-view.md deleted file mode 100644 index 44858f831..000000000 --- a/skills/lark-sheets/references/lark-sheets-update-filter-view.md +++ /dev/null @@ -1,24 +0,0 @@ - -# sheets +update-filter-view(更新筛选视图) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +update-filter-view`。 - -## 命令 - -```bash -lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--range` | 否 | 新的筛选范围 | -| `--filter-view-name` | 否 | 新的显示名称 | diff --git a/skills/lark-sheets/references/lark-sheets-update-float-image.md b/skills/lark-sheets/references/lark-sheets-update-float-image.md deleted file mode 100644 index 5cc5714cd..000000000 --- a/skills/lark-sheets/references/lark-sheets-update-float-image.md +++ /dev/null @@ -1,52 +0,0 @@ - -# 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) diff --git a/skills/lark-sheets/references/lark-sheets-write-image.md b/skills/lark-sheets/references/lark-sheets-write-image.md deleted file mode 100644 index 296c48d7e..000000000 --- a/skills/lark-sheets/references/lark-sheets-write-image.md +++ /dev/null @@ -1,65 +0,0 @@ - -# sheets +write-image(写入图片到单元格) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +write-image`。 - -特性: - -- 将本地图片文件写入到电子表格的指定单元格 -- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC -- `--range` 的起始和结束单元格必须相同(单个单元格),如 `A1` 或 `<sheetId>!B2:B2` -- `--name` 默认取 `--image` 的文件名 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 写入图片到指定单元格 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!B2:B2" \ - --image "./logo.png" - -# 使用 URL + sheet-id,指定单个单元格 -lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "C3" \ - --image "./chart.jpg" - -# 自定义图片名称 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:A1" \ - --image "./output.png" --name "revenue_chart.png" - -# 仅预览参数(不发请求) -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!B2:B2" --image "./logo.png" --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|----|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 是 | 目标单元格:`<sheetId>!A1:A1`、`A1`(需配合 `--sheet-id`) | -| `--sheet-id <id>` | 否 | 工作表 ID | -| `--image <path>` | 是 | 本地图片文件的**相对路径**(必须在当前目录下,如 `./logo.png`;不支持绝对路径)| -| `--name <filename>` | 否 | 图片文件名(含扩展名,默认取 `--image` 的文件名) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `spreadsheetToken` — 表格 token -- `updateRange` — 图片写入的单元格范围 -- `revision` — 工作表版本号 - -## 参考 - -- [lark-sheets-write](lark-sheets-write.md) — 写入普通单元格数据 -- [lark-sheets-read](lark-sheets-read.md) — 写入前可先 read 验证范围 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-write.md b/skills/lark-sheets/references/lark-sheets-write.md deleted file mode 100644 index 5edbbadca..000000000 --- a/skills/lark-sheets/references/lark-sheets-write.md +++ /dev/null @@ -1,60 +0,0 @@ - -# sheets +write(写入单元格 / 覆盖写入) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -本 skill 对应 shortcut:`lark-cli sheets +write`。 - -- `--values` 必须是二维数组 JSON -- 内置尺寸校验:最多 5000 行、每行最多 100 列 -- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2` -- 若 `--range` 只给了 `<sheetId>` 或单个起始单元格,工具会按 `--values` 的尺寸自动展开为矩形范围 - -> [!CAUTION] -> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 - -## 命令 - -```bash -# 覆盖写入一个矩形区域 -lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \ - --range "<sheetId>!A1:B2" \ - --values '[["name","age"],["alice",18]]' - -# 已有 --sheet-id 时,可直接写相对范围 -lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "<sheetId>" --range "C2" \ - --values '[["hello"]]' - -# 只给 sheetId:会从 A1 开始,按 values 尺寸自动展开 -lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "<sheetId>" \ - --values '[["hello","world"]]' - -# 仅预览参数(不发请求) -lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" --range "<sheetId>!A1:B2" \ - --values '[["name","age"],["alice",18]]' --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) | -| `--range <range>` | 否 | 写入范围:`<sheetId>!A1:D10`、`A1:D10` / `C2`(需配合 `--sheet-id`),或 `<sheetId>` | -| `--sheet-id <id>` | 否 | 工作表 ID(不提供 `--range` 时生效) | -| `--values <json>` | 是 | 二维数组 JSON(写入值) | -| `--dry-run` | 否 | 仅打印参数,不执行请求 | - -## 输出 - -JSON,包含: - -- `updated_range/updated_rows/updated_columns/updated_cells` -- `revision` - -## 参考 - -- [lark-sheets-read](lark-sheets-read.md) — 写入前可先 read 验证范围 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/tests/cli_e2e/sheets/coverage.md b/tests/cli_e2e/sheets/coverage.md index 9111da2d3..c1ab1a447 100644 --- a/tests/cli_e2e/sheets/coverage.md +++ b/tests/cli_e2e/sheets/coverage.md @@ -1,15 +1,17 @@ # Sheets CLI E2E Coverage ## Metrics -- Denominator: 26 leaf commands -- Covered: 14 -- Coverage: 53.8% +- Denominator: 30 leaf commands +- Covered: 18 +- Coverage: 60.0% ## Summary - TestSheets_CRUDE2EWorkflow: proves `+create`, `+info`, `+write`, `+read`, `+append`, `+find`, and `+export`; key `t.Run(...)` proof points are `create spreadsheet with +create as bot`, `read data with +read as bot`, `find cells with +find as bot`, and `export spreadsheet with +export as bot`. - TestSheets_CreateWorkflowAsUser: proves the UAT path for `sheets +create` and `sheets +info` through `create spreadsheet with +create as user` and `get spreadsheet info with +info as user`. - TestSheets_SpreadsheetsResource: proves direct `spreadsheets create`, `spreadsheets get`, and `spreadsheets patch`. - TestSheets_FilterWorkflow: proves `spreadsheet.sheet.filters create`, `get`, `update`, and `delete`, with supporting sheet setup through `+create`, `+info`, and `+write`. +- TestSheets_SheetShortcutsDryRun: proves request shapes for `+create-sheet`, `+copy-sheet`, `+delete-sheet`, and `+update-sheet` without hitting live APIs. +- TestSheets_SheetShortcutsWorkflow: proves live `+create-sheet`, `+copy-sheet`, `+update-sheet`, and `+delete-sheet` flows against a real spreadsheet, with verification through `+info`. - Cleanup note: workflow-created spreadsheets are cleaned up via `drive +delete --type sheet`; those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface. ## Command Table @@ -20,7 +22,10 @@ | ✓ | sheets +append | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/append rows with +append as bot | `--spreadsheet-token`; `--sheet-id`; `--range`; `--values` | | | ✕ | sheets +batch-set-style | shortcut | | none | no style workflow yet | | ✓ | sheets +create | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/create spreadsheet with +create as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/create spreadsheet with initial data as bot; sheets_create_workflow_test.go::TestSheets_CreateWorkflowAsUser/create spreadsheet with +create as user | `--title` | | +| ✓ | sheets +create-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/create sheet with +create-sheet as bot | `--spreadsheet-token`; optional `--title`; optional `--index` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go | +| ✓ | sheets +copy-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/copy sheet with +copy-sheet as bot | `--spreadsheet-token`; `--sheet-id`; optional `--title`; optional `--index` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go | | ✕ | sheets +delete-dimension | shortcut | | none | no dimension workflow yet | +| ✓ | sheets +delete-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/delete sheet with +delete-sheet as bot | `--spreadsheet-token`; `--sheet-id` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go | | ✓ | sheets +export | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/export spreadsheet with +export as bot | `--spreadsheet-token`; `--file-extension` | | | ✓ | sheets +find | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/find cells with +find as bot | `--spreadsheet-token`; `--sheet-id`; `--find`; `--range` | | | ✓ | sheets +info | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/get spreadsheet info with +info as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/get sheet info as bot; sheets_create_workflow_test.go::TestSheets_CreateWorkflowAsUser/get spreadsheet info with +info as user | `--spreadsheet-token` | | @@ -32,6 +37,7 @@ | ✕ | sheets +set-style | shortcut | | none | no style workflow yet | | ✕ | sheets +unmerge-cells | shortcut | | none | no merge workflow yet | | ✕ | sheets +update-dimension | shortcut | | none | no dimension workflow yet | +| ✓ | sheets +update-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/update sheet with +update-sheet as bot | `--spreadsheet-token`; `--sheet-id`; scalar update flags; optional protect fields | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go | | ✓ | sheets +write | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/write data with +write as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/write test data for filtering as bot | `--spreadsheet-token`; `--sheet-id`; `--range`; `--values` | | | ✕ | sheets +write-image | shortcut | | none | no image workflow yet | | ✓ | sheets spreadsheet.sheet.filters create | api | sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/create filter with spreadsheet.sheet.filters create as bot | `spreadsheet_token`; `sheet_id` in `--params`; filter JSON in `--data` | | diff --git a/tests/cli_e2e/sheets/sheets_sheet_shortcuts_dryrun_test.go b/tests/cli_e2e/sheets/sheets_sheet_shortcuts_dryrun_test.go new file mode 100644 index 000000000..e51833e25 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_sheet_shortcuts_dryrun_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func setSheetsDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +func TestSheets_SheetShortcutsDryRunRejectsURLAndTokenTogether(t *testing.T) { + setSheetsDryRunEnv(t) + + tests := []struct { + name string + args []string + }{ + { + name: "create-sheet", + args: []string{ + "sheets", "+create-sheet", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--spreadsheet-token", "shtTOKEN", + "--title", "Data", + "--dry-run", + }, + }, + { + name: "copy-sheet", + args: []string{ + "sheets", "+copy-sheet", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Copy", + "--dry-run", + }, + }, + { + name: "delete-sheet", + args: []string{ + "sheets", "+delete-sheet", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dry-run", + }, + }, + { + name: "update-sheet", + args: []string{ + "sheets", "+update-sheet", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Renamed", + "--dry-run", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + }) + } +} + +func TestSheets_SheetShortcutsDryRunRejectsEmptyTitle(t *testing.T) { + setSheetsDryRunEnv(t) + + tests := []struct { + name string + args []string + }{ + { + name: "create-sheet", + args: []string{ + "sheets", "+create-sheet", + "--spreadsheet-token", "shtDryRun", + "--title", "", + "--dry-run", + }, + }, + { + name: "copy-sheet", + args: []string{ + "sheets", "+copy-sheet", + "--spreadsheet-token", "shtDryRun", + "--sheet-id", "sheet1", + "--title", "", + "--dry-run", + }, + }, + { + name: "update-sheet", + args: []string{ + "sheets", "+update-sheet", + "--spreadsheet-token", "shtDryRun", + "--sheet-id", "sheet1", + "--title", "", + "--dry-run", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "must not be empty") { + t.Fatalf("expected empty-title error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + }) + } +} + +func TestSheets_SheetShortcutsDryRun(t *testing.T) { + setSheetsDryRunEnv(t) + + tests := []struct { + name string + args []string + wantURL string + wantFn func(t *testing.T, out string) + }{ + { + name: "create-sheet", + args: []string{ + "sheets", "+create-sheet", + "--spreadsheet-token", "shtDryRun", + "--title", "Data", + "--index", "0", + "--dry-run", + }, + wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update", + wantFn: func(t *testing.T, out string) { + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "Data", gjson.Get(out, "api.0.body.requests.0.addSheet.properties.title").String(), "stdout:\n%s", out) + require.Equal(t, int64(0), gjson.Get(out, "api.0.body.requests.0.addSheet.properties.index").Int(), "stdout:\n%s", out) + }, + }, + { + name: "copy-sheet", + args: []string{ + "sheets", "+copy-sheet", + "--spreadsheet-token", "shtDryRun", + "--sheet-id", "sheet1", + "--title", "Copy", + "--index", "2", + "--dry-run", + }, + wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update", + wantFn: func(t *testing.T, out string) { + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.copySheet.source.sheetId").String(), "stdout:\n%s", out) + require.Equal(t, "Copy", gjson.Get(out, "api.0.body.requests.0.copySheet.destination.title").String(), "stdout:\n%s", out) + require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out) + require.Equal(t, "<copied_sheet_id>", gjson.Get(out, "api.1.body.requests.0.updateSheet.properties.sheetId").String(), "stdout:\n%s", out) + require.Equal(t, int64(2), gjson.Get(out, "api.1.body.requests.0.updateSheet.properties.index").Int(), "stdout:\n%s", out) + }, + }, + { + name: "delete-sheet", + args: []string{ + "sheets", "+delete-sheet", + "--spreadsheet-token", "shtDryRun", + "--sheet-id", "sheet1", + "--dry-run", + }, + wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update", + wantFn: func(t *testing.T, out string) { + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.deleteSheet.sheetId").String(), "stdout:\n%s", out) + }, + }, + { + name: "update-sheet", + args: []string{ + "sheets", "+update-sheet", + "--spreadsheet-token", "shtDryRun", + "--sheet-id", "sheet1", + "--title", "Renamed", + "--hidden=false", + "--frozen-row-count", "2", + "--frozen-col-count", "1", + "--lock", "LOCK", + "--lock-info", "private", + "--user-ids", `["ou_1"]`, + "--user-id-type", "open_id", + "--dry-run", + }, + wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update", + wantFn: func(t *testing.T, out string) { + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "open_id", gjson.Get(out, "api.0.params.user_id_type").String(), "stdout:\n%s", out) + require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.sheetId").String(), "stdout:\n%s", out) + require.Equal(t, "Renamed", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.title").String(), "stdout:\n%s", out) + require.Equal(t, false, gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.hidden").Bool(), "stdout:\n%s", out) + require.Equal(t, int64(2), gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.frozenRowCount").Int(), "stdout:\n%s", out) + require.Equal(t, int64(1), gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.frozenColCount").Int(), "stdout:\n%s", out) + require.Equal(t, "LOCK", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.protect.lock").String(), "stdout:\n%s", out) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, tt.wantURL, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + tt.wantFn(t, out) + }) + } +} diff --git a/tests/cli_e2e/sheets/sheets_sheet_shortcuts_workflow_test.go b/tests/cli_e2e/sheets/sheets_sheet_shortcuts_workflow_test.go new file mode 100644 index 000000000..dbaf3f304 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_sheet_shortcuts_workflow_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestSheets_SheetShortcutsWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + spreadsheetToken := "" + originalSheetID := "" + createdSheetID := "" + copiedSheetID := "" + + t.Run("create spreadsheet with +create as bot", func(t *testing.T) { + spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheet-shortcuts-"+suffix, "bot") + }) + + t.Run("get initial sheet info as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + originalSheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String() + require.NotEmpty(t, originalSheetID, "sheet_id should not be empty, stdout: %s", result.Stdout) + }) + + t.Run("create sheet with +create-sheet as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+create-sheet", + "--spreadsheet-token", spreadsheetToken, + "--title", "data-" + suffix, + "--index", "1", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + createdSheetID = gjson.Get(result.Stdout, "data.sheet_id").String() + require.NotEmpty(t, createdSheetID, "created sheet_id should not be empty, stdout: %s", result.Stdout) + assert.Equal(t, "data-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String()) + }) + + t.Run("copy sheet with +copy-sheet as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, createdSheetID, "created sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+copy-sheet", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", createdSheetID, + "--title", "copy-" + suffix, + "--index", "2", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + copiedSheetID = gjson.Get(result.Stdout, "data.sheet_id").String() + require.NotEmpty(t, copiedSheetID, "copied sheet_id should not be empty, stdout: %s", result.Stdout) + assert.NotEqual(t, createdSheetID, copiedSheetID) + assert.Equal(t, "copy-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String()) + }) + + t.Run("update sheet with +update-sheet as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, createdSheetID, "created sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+update-sheet", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", createdSheetID, + "--title", "renamed-" + suffix, + "--hidden=true", + "--frozen-row-count", "2", + "--frozen-col-count", "1", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, createdSheetID, gjson.Get(result.Stdout, "data.sheet_id").String()) + assert.Equal(t, "renamed-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String()) + assert.Equal(t, true, gjson.Get(result.Stdout, "data.sheet.hidden").Bool()) + }) + + t.Run("verify updated sheet through +info as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, createdSheetID, "created sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + var matched gjson.Result + for _, item := range gjson.Get(result.Stdout, "data.sheets.sheets").Array() { + if gjson.Get(item.Raw, "sheet_id").String() == createdSheetID { + matched = item + break + } + } + require.True(t, matched.Exists(), "updated sheet %s should exist, stdout: %s", createdSheetID, result.Stdout) + assert.Equal(t, "renamed-"+suffix, gjson.Get(matched.Raw, "title").String()) + assert.Equal(t, true, gjson.Get(matched.Raw, "hidden").Bool()) + assert.Equal(t, int64(2), gjson.Get(matched.Raw, "grid_properties.frozen_row_count").Int()) + assert.Equal(t, int64(1), gjson.Get(matched.Raw, "grid_properties.frozen_column_count").Int()) + }) + + t.Run("delete sheet with +delete-sheet as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, copiedSheetID, "copied sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+delete-sheet", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", copiedSheetID, + }, + DefaultAs: "bot", + Yes: true, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, true, gjson.Get(result.Stdout, "data.deleted").Bool()) + assert.Equal(t, copiedSheetID, gjson.Get(result.Stdout, "data.sheet_id").String()) + }) + + t.Run("verify deleted sheet through +info as bot", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, copiedSheetID, "copied sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + for _, item := range gjson.Get(result.Stdout, "data.sheets.sheets").Array() { + if gjson.Get(item.Raw, "sheet_id").String() == copiedSheetID { + t.Fatalf("deleted sheet %s should not exist, stdout: %s", copiedSheetID, result.Stdout) + } + } + }) +}