项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

353 行
14 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// maxReplaceParts matches the server-side cap declared in meta_data.json
// ("最少1条,最多200条"). Enforced client-side so a too-large batch fails fast
// with a clear message instead of a 400 from the backend.
const maxReplaceParts = 200
// SlidesReplaceSlide wraps slides.xml_presentation.slide.replace with specific
// value-adds over the raw auto-generated command:
//
// 1. It accepts --presentation as token / slides URL / wiki URL (and resolves
// wiki tokens), same as other slides shortcuts.
// 2. For every `block_replace` part it auto-injects `id="<block_id>"` into the
// root element of `replacement`. The backend requires the replacement
// fragment's root carry that id and returns 3350001 otherwise; the
// requirement is undocumented and catches callers repeatedly, so we fix it
// at the CLI layer.
// 3. For `<shape>` elements it auto-injects `<content/>` when missing. The
// SML 2.0 schema requires every shape to carry a content child; omitting
// it triggers 3350001.
// 4. On 3350001 errors it enriches the hint with context-specific guidance
// so AI agents can self-correct.
//
// `str_replace` is intentionally NOT exposed: product direction is that
// slide edits go through structural (block-level) operations only. The backend
// still accepts str_replace, but the CLI rejects it up front.
var SlidesReplaceSlide = common.Shortcut{
Service: "slides",
Command: "+replace-slide",
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
{Name: "parts", Desc: "JSON array of replace parts (each: {action: block_replace|block_insert, ...}); max 200", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision (-1 = latest; pass a specific number for optimistic locking)"},
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}
parts, err := parseReplaceParts(runtime.Str("parts"))
if err != nil {
return err
}
if err := validateReplaceParts(parts); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
parts, err := parseReplaceParts(runtime.Str("parts"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateReplaceParts(parts); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
// Apply the same id-injection the real Execute does, so dry-run body
// shows what will actually be sent.
injected, err := injectBlockReplaceIDs(parts)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideID := runtime.Str("slide-id")
query := map[string]interface{}{
"slide_id": slideID,
"revision_id": runtime.Int("revision-id"),
}
if tid := runtime.Str("tid"); tid != "" {
query["tid"] = tid
}
body := map[string]interface{}{"parts": injected}
dry := common.NewDryRunAPI()
presentationID := ref.Token
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → replace slide parts").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc(fmt.Sprintf("Replace %d part(s) on slide %s", len(parts), slideID))
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
validate.EncodePathSegment(presentationID),
)).
Params(query).
Body(body)
return dry.Set("parts_count", len(parts))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideID := strings.TrimSpace(runtime.Str("slide-id"))
parts, err := parseReplaceParts(runtime.Str("parts"))
if err != nil {
return err
}
if err := validateReplaceParts(parts); err != nil {
return err
}
injected, err := injectBlockReplaceIDs(parts)
if err != nil {
return err
}
query := map[string]interface{}{
"slide_id": slideID,
"revision_id": runtime.Int("revision-id"),
}
if tid := strings.TrimSpace(runtime.Str("tid")); tid != "" {
query["tid"] = tid
}
body := map[string]interface{}{"parts": injected}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
validate.EncodePathSegment(presentationID),
)
data, err := runtime.CallAPITyped("POST", url, query, body)
if err != nil {
return enrichSlidesReplaceError(err)
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"slide_id": slideID,
"parts_count": len(injected),
}
// Presence check (not `v > 0`) mirrors the failed_part_index / failed_reason
// branches below, so behavior stays consistent across the three fields.
if _, ok := data["revision_id"]; ok {
result["revision_id"] = int(common.GetFloat(data, "revision_id"))
}
// Backend reports partial failures via failed_part_index / failed_reason.
// Surface them untouched so the caller can react.
if raw, ok := data["failed_part_index"]; ok {
result["failed_part_index"] = raw
}
if raw, ok := data["failed_reason"]; ok {
result["failed_reason"] = raw
}
runtime.Out(result, nil)
return nil
},
}
// replacePart is the normalized (post-JSON) representation of one entry in the
// parts array. Fields are nullable so we can tell "not provided" from "empty".
type replacePart struct {
Action string
Replacement *string
BlockID *string
Insertion *string
InsertBeforeBlockID *string
}
// parseReplaceParts decodes the --parts JSON into typed structs.
//
// Accepts JSON with extra keys (pattern / is_multiple) so that a user who
// copy-pasted a doc example doesn't get a decoder error; those keys are
// ignored because str_replace isn't exposed. validateReplaceParts enforces
// that nothing from the str_replace family actually gets used.
func parseReplaceParts(raw string) ([]replacePart, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts cannot be empty").WithParam("--parts")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts invalid JSON, must be an array of objects: %v", err).WithParam("--parts").WithCause(err)
}
out := make([]replacePart, 0, len(decoded))
for i, m := range decoded {
p := replacePart{}
if v, ok := m["action"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i).WithParam("--parts")
}
p.Action = s
}
if v, ok := m["replacement"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement must be a string", i).WithParam("--parts")
}
p.Replacement = &s
}
if v, ok := m["block_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].block_id must be a string", i).WithParam("--parts")
}
p.BlockID = &s
}
if v, ok := m["insertion"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion must be a string", i).WithParam("--parts")
}
p.Insertion = &s
}
if v, ok := m["insert_before_block_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insert_before_block_id must be a string", i).WithParam("--parts")
}
p.InsertBeforeBlockID = &s
}
out = append(out, p)
}
return out, nil
}
const larkCodeSlidesInvalidParam = 3350001
// slides3350001Hint is the generic checklist attached to 3350001 errors.
// 3350001 is a catch-all on the backend; listing the common root causes gives
// AI agents and humans a concrete starting point. Mixed block_replace+block_insert
// batches are supported, so splitting them is deliberately NOT suggested.
const slides3350001Hint = "common causes: (1) block_id not found in current slide — re-run slide.get for latest XML; (2) invalid XML structure or unsupported element; (3) element coordinates exceed slide bounds (960×540)"
// enrichSlidesReplaceError attaches slides3350001Hint when the API returns
// 3350001 (invalid param). Other error codes pass through untouched.
func enrichSlidesReplaceError(err error) error {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != larkCodeSlidesInvalidParam {
return err
}
// Only fall back to the generic checklist when no upstream hint is
// already attached — don't clobber a more specific hint set by the
// backend or an earlier wrapper. p points at the embedded Problem, so
// the mutation is reflected in the returned err.
if p.Hint == "" {
p.Hint = slides3350001Hint
}
return err
}
// validateReplaceParts enforces CLI-level invariants:
// - size is within [1, 200]
// - action is one of the exposed actions (block_replace / block_insert)
// - per-action required fields are present
func validateReplaceParts(parts []replacePart) error {
if len(parts) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item").WithParam("--parts")
}
if len(parts) > maxReplaceParts {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts).WithParam("--parts")
}
for i, p := range parts {
switch p.Action {
case "block_replace":
if p.BlockID == nil || strings.TrimSpace(*p.BlockID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty block_id", i).WithParam("--parts")
}
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i).WithParam("--parts")
}
case "block_insert":
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i).WithParam("--parts")
}
case "str_replace":
// Backend still accepts str_replace, but product decision is to
// force structural edits through the CLI. Block it up-front so
// users don't build tooling around an option we won't keep.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action).WithParam("--parts")
case "":
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i).WithParam("--parts")
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action).WithParam("--parts")
}
}
return nil
}
// injectBlockReplaceIDs rewrites each block_replace part's `replacement` so
// that the root element carries id="<block_id>". Backend (3350001) requires
// this; doing it in the CLI means users write natural-looking XML (e.g.
// `<shape type="rect">…</shape>`) and get the id stitched in automatically.
//
// Returns a slice of `map[string]interface{}` ready to be encoded as the
// request body, preserving field order handed to the JSON encoder.
func injectBlockReplaceIDs(parts []replacePart) ([]map[string]interface{}, error) {
out := make([]map[string]interface{}, 0, len(parts))
for i, p := range parts {
m := map[string]interface{}{"action": p.Action}
switch p.Action {
case "block_replace":
fixed, err := ensureXMLRootID(*p.Replacement, *p.BlockID)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithParam("--parts").WithCause(err)
}
fixed = ensureShapeHasContent(fixed)
m["block_id"] = *p.BlockID
m["replacement"] = fixed
case "block_insert":
m["insertion"] = ensureShapeHasContent(*p.Insertion)
if p.InsertBeforeBlockID != nil {
m["insert_before_block_id"] = *p.InsertBeforeBlockID
}
}
out = append(out, m)
}
return out, nil
}