Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions shortcuts/doc/docs_batch_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/larksuite/cli/shortcuts/common"
)

// batchUpdateOp is one entry in the --operations JSON array. The field set
// mirrors the flags of `docs +update` so an operations file is effectively a
// serialized batch of +update invocations against a single document.
//
// Omit-empty is deliberate: a caller may submit an append / overwrite op
// without any selection, and the MCP call itself ignores unset fields.
type batchUpdateOp struct {
Mode string `json:"mode"`
Markdown string `json:"markdown,omitempty"`
SelectionWithEllipsis string `json:"selection_with_ellipsis,omitempty"`
SelectionByTitle string `json:"selection_by_title,omitempty"`
NewTitle string `json:"new_title,omitempty"`
}

// batchUpdateResult is the per-op entry in the shortcut's JSON response.
// Success is explicit (not derived from the presence of Error) so callers
// can script against a stable schema without having to infer state.
type batchUpdateResult struct {
Index int `json:"index"`
Mode string `json:"mode"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Result map[string]interface{} `json:"result,omitempty"`
}

var validBatchOnError = map[string]bool{
"stop": true,
"continue": true,
}

// DocsBatchUpdate applies a sequence of update-doc operations to a single
// document. It is an orchestration convenience — there is no server-side
// transaction, so "batch" here means "one CLI call, shared validation,
// unified reporting", not atomic. On a mid-batch failure the document is
// left in a partial-apply state; pair with --on-error=stop + `docs +fetch`
// to recover manually. This tradeoff is explicit in the shortcut's
// description.
var DocsBatchUpdate = common.Shortcut{
Service: "docs",
Command: "+batch-update",
Description: "Apply a sequence of update-doc operations to a single document. Sequential execution, not atomic — partial failure leaves the document in a partial-apply state. Pair with --on-error=stop + docs +fetch to recover.",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "operations", Desc: "JSON array of operations (each entry: mode, markdown, selection_with_ellipsis, selection_by_title, new_title)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "on-error", Default: "stop", Desc: "behavior when a single operation fails: stop | continue"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
onError := runtime.Str("on-error")
if !validBatchOnError[onError] {
return common.FlagErrorf("invalid --on-error %q, valid: stop | continue", onError)
}
ops, err := parseBatchUpdateOps(runtime.Str("operations"))
if err != nil {
return err

Check warning on line 71 in shortcuts/doc/docs_batch_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_batch_update.go#L71

Added line #L71 was not covered by tests
}
for i, op := range ops {
if err := validateBatchUpdateOp(op); err != nil {
return common.FlagErrorf("--operations[%d]: %s", i, err.Error())
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ops, err := parseBatchUpdateOps(runtime.Str("operations"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())

Check warning on line 83 in shortcuts/doc/docs_batch_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_batch_update.go#L83

Added line #L83 was not covered by tests
}
d := common.NewDryRunAPI().
Desc(fmt.Sprintf("%d-op sequential batch against doc %q; MCP tool: update-doc", len(ops), runtime.Str("doc"))).
Set("mcp_tool", "update-doc").
Set("op_count", len(ops))
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
for i, op := range ops {
args := buildBatchUpdateArgs(runtime.Str("doc"), op)
d.POST(mcpEndpoint).
Desc(fmt.Sprintf("[%d/%d] %s", i+1, len(ops), op.Mode)).
Body(map[string]interface{}{
"method": "tools/call",
"params": map[string]interface{}{
"name": "update-doc",
"arguments": args,
},
})
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ops, err := parseBatchUpdateOps(runtime.Str("operations"))
if err != nil {
return err

Check warning on line 107 in shortcuts/doc/docs_batch_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_batch_update.go#L107

Added line #L107 was not covered by tests
}
stopOnError := runtime.Str("on-error") == "stop"
results := make([]batchUpdateResult, 0, len(ops))
successCount := 0

for i, op := range ops {
// Re-run the same static warnings the single-op shortcut emits so
// batch users get the same advisory signal per-op.
for _, w := range docsUpdateWarnings(op.Mode, op.Markdown) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: --operations[%d]: %s\n", i, w)

Check warning on line 117 in shortcuts/doc/docs_batch_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_batch_update.go#L117

Added line #L117 was not covered by tests
}

args := buildBatchUpdateArgs(runtime.Str("doc"), op)
out, callErr := common.CallMCPTool(runtime, "update-doc", args)
if callErr != nil {
results = append(results, batchUpdateResult{
Index: i, Mode: op.Mode, Success: false, Error: callErr.Error(),
})
if stopOnError {
fmt.Fprintf(runtime.IO().ErrOut,
"error: --operations[%d] failed; stopping (--on-error=stop); %d/%d applied before the failure\n",
i, successCount, len(ops))
runtime.Out(map[string]interface{}{
"doc": runtime.Str("doc"),
"total": len(ops),
"applied": successCount,
"results": results,
"stopped_early": true,
}, nil)
return callErr
}
continue

Check warning on line 139 in shortcuts/doc/docs_batch_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_batch_update.go#L139

Added line #L139 was not covered by tests
}
normalizeDocsUpdateResult(out, op.Markdown)
results = append(results, batchUpdateResult{
Index: i, Mode: op.Mode, Success: true, Result: out,
})
successCount++
}

runtime.Out(map[string]interface{}{
"doc": runtime.Str("doc"),
"total": len(ops),
"applied": successCount,
"results": results,
"stopped_early": false,
}, nil)
return nil
},
}

// buildBatchUpdateArgs constructs the update-doc MCP arguments for one op,
// omitting empty optional fields so the server sees the same shape as a
// single-op `docs +update` call.
func buildBatchUpdateArgs(docID string, op batchUpdateOp) map[string]interface{} {
args := map[string]interface{}{
"doc_id": docID,
"mode": op.Mode,
}
if op.Markdown != "" {
args["markdown"] = op.Markdown
}
if op.SelectionWithEllipsis != "" {
args["selection_with_ellipsis"] = op.SelectionWithEllipsis
}
if op.SelectionByTitle != "" {
args["selection_by_title"] = op.SelectionByTitle
}
if op.NewTitle != "" {
args["new_title"] = op.NewTitle
}
return args
}

// parseBatchUpdateOps accepts a JSON array and returns the typed ops slice
// with a clearer error on the two mistakes users make most often: passing a
// single object instead of an array, or passing an empty array.
func parseBatchUpdateOps(raw string) ([]batchUpdateOp, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, common.FlagErrorf("--operations is required")
}
if !strings.HasPrefix(trimmed, "[") {
return nil, common.FlagErrorf("--operations must be a JSON array of operation objects (received object or scalar)")
}
var ops []batchUpdateOp
if err := json.Unmarshal([]byte(trimmed), &ops); err != nil {
return nil, common.FlagErrorf("--operations is not valid JSON: %s", err.Error())
}
if len(ops) == 0 {
return nil, common.FlagErrorf("--operations must contain at least one operation")
}
return ops, nil
}

// validateBatchUpdateOp reuses the same rule set as `docs +update`. Keeping
// it duplicated (rather than factoring the original Validate into a shared
// helper) is a deliberate small trade: the batch shortcut calls this in its
// own Validate phase, before any MCP work, so a single malformed op fails
// the whole invocation up front instead of after N successful ops.
func validateBatchUpdateOp(op batchUpdateOp) error {
if !validModes[op.Mode] {
return fmt.Errorf("invalid mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", op.Mode)
}
if op.Mode != "delete_range" && op.Markdown == "" {
return fmt.Errorf("mode=%s requires markdown", op.Mode)
}
if op.SelectionWithEllipsis != "" && op.SelectionByTitle != "" {
return fmt.Errorf("selection_with_ellipsis and selection_by_title are mutually exclusive")
}
if needsSelection[op.Mode] && op.SelectionWithEllipsis == "" && op.SelectionByTitle == "" {
return fmt.Errorf("mode=%s requires selection_with_ellipsis or selection_by_title", op.Mode)
}
if op.SelectionByTitle != "" {
if err := validateSelectionByTitle(op.SelectionByTitle); err != nil {
return err
}
}
return nil
}
Loading
Loading