mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat(sheets): add cell operation shortcuts for merge, replace, and style (#412)
Add 5 new sheet shortcuts for cell operations: - +merge-cells: merge cells with MERGE_ALL/MERGE_ROWS/MERGE_COLUMNS - +unmerge-cells: split merged cells - +replace: find and replace cell values - +set-style: set cell style (font, color, alignment, border) - +batch-set-style: batch set styles for multiple ranges Includes unit tests (81-89% coverage) and skill reference docs.
This commit is contained in:
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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", 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
@@ -0,0 +1,539 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── MergeCells ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
|
||||
t.Fatalf("expected sheet-id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
|
||||
}, nil)
|
||||
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `merge_cells`) {
|
||||
t.Fatalf("DryRun URL missing merge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
|
||||
t.Fatalf("DryRun missing mergeType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "spreadsheetToken") {
|
||||
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UnmergeCells ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
err := SheetUnmergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `unmerge_cells`) {
|
||||
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun missing range: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Replace ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetReplaceValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet2!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet1!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
|
||||
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `replace`) {
|
||||
t.Fatalf("DryRun URL missing replace: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"find":"old"`) {
|
||||
t.Fatalf("DryRun missing find: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"replacement":"new"`) {
|
||||
t.Fatalf("DryRun missing replacement: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"match_case":true`) {
|
||||
t.Fatalf("DryRun missing match_case: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRunNoRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
// When no range specified, range defaults to sheet-id
|
||||
if !strings.Contains(got, `"range":"sheet1"`) {
|
||||
t.Fatalf("DryRun range should default to sheet-id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"replace_result": map[string]interface{}{
|
||||
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_cells") {
|
||||
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["find"] != "hello" || body["replacement"] != "world" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SetStyle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{invalid}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `[{"bold":true}]`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `"bold"`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `null`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
|
||||
}, nil)
|
||||
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `/style`) {
|
||||
t.Fatalf("DryRun URL missing /style: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bold":true`) {
|
||||
t.Fatalf("DryRun missing style: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updatedCells") {
|
||||
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:B2" {
|
||||
t.Fatalf("unexpected range: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── BatchSetStyle ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected empty array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `styles_batch_update`) {
|
||||
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(4), "revision": float64(90),
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
|
||||
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
77
shortcuts/sheets/sheet_merge_cells.go
Normal file
77
shortcuts/sheets/sheet_merge_cells.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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 (<sheetId>!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
|
||||
},
|
||||
}
|
||||
112
shortcuts/sheets/sheet_replace.go
Normal file
112
shortcuts/sheets/sheet_replace.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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 (<sheetId>!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
|
||||
},
|
||||
}
|
||||
95
shortcuts/sheets/sheet_set_style.go
Normal file
95
shortcuts/sheets/sheet_set_style.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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 (<sheetId>!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 := normalizeSheetRange(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 := normalizeSheetRange(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
|
||||
},
|
||||
}
|
||||
74
shortcuts/sheets/sheet_unmerge_cells.go
Normal file
74
shortcuts/sheets/sheet_unmerge_cells.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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 (<sheetId>!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
|
||||
},
|
||||
}
|
||||
@@ -16,5 +16,10 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetFind,
|
||||
SheetCreate,
|
||||
SheetExport,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
SheetReplace,
|
||||
SheetSetStyle,
|
||||
SheetBatchSetStyle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+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 |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
53
skills/lark-sheets/references/lark-sheets-batch-set-style.md
Normal file
53
skills/lark-sheets/references/lark-sheets-batch-set-style.md
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
# 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":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
|
||||
|
||||
# 同一样式应用到多个范围
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:B2","<sheetId>!D4:E5"],"style":{"hAlign":1,"font":{"bold":true}}}]'
|
||||
|
||||
# 仅预览
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:B2"],"style":{"backColor":"#0000ff"}}]' --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) |
|
||||
| `--data <json>` | 是 | 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) — 认证和全局参数
|
||||
47
skills/lark-sheets/references/lark-sheets-merge-cells.md
Normal file
47
skills/lark-sheets/references/lark-sheets-merge-cells.md
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# 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) — 认证和全局参数
|
||||
62
skills/lark-sheets/references/lark-sheets-replace.md
Normal file
62
skills/lark-sheets/references/lark-sheets-replace.md
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
# 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) — 认证和全局参数
|
||||
71
skills/lark-sheets/references/lark-sheets-set-style.md
Normal file
71
skills/lark-sheets/references/lark-sheets-set-style.md
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
# 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) — 认证和全局参数
|
||||
46
skills/lark-sheets/references/lark-sheets-unmerge-cells.md
Normal file
46
skills/lark-sheets/references/lark-sheets-unmerge-cells.md
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
# 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) — 认证和全局参数
|
||||
Reference in New Issue
Block a user