mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
2 Commits
feat/apps-
...
feat/outpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3708c2e78 | ||
|
|
39d60cb706 |
@@ -19,7 +19,8 @@ import (
|
||||
// Complex values (maps, arrays) are printed as indented JSON with Go's default
|
||||
// HTML escaping (<, >, & → <, >, &).
|
||||
func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
return jqFilter(w, data, expr, false)
|
||||
_, err := jqFilter(w, data, expr, false)
|
||||
return err
|
||||
}
|
||||
|
||||
// JqFilterRaw is like JqFilter but disables HTML escaping when re-marshaling
|
||||
@@ -27,17 +28,30 @@ func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
// carries XML/HTML content that must survive --jq '.data.document' style
|
||||
// projections without getting mangled into < escapes.
|
||||
func JqFilterRaw(w io.Writer, data interface{}, expr string) error {
|
||||
_, err := jqFilter(w, data, expr, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// JqFilterCount applies expr to data, writes results to w, and returns the
|
||||
// number of result values produced (0 = empty result; drives the on-demand
|
||||
// full-only miss hint).
|
||||
func JqFilterCount(w io.Writer, data interface{}, expr string) (int, error) {
|
||||
return jqFilter(w, data, expr, false)
|
||||
}
|
||||
|
||||
// JqFilterRawCount is JqFilterCount with HTML escaping disabled (see JqFilterRaw).
|
||||
func JqFilterRawCount(w io.Writer, data interface{}, expr string) (int, error) {
|
||||
return jqFilter(w, data, expr, true)
|
||||
}
|
||||
|
||||
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) (int, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Normalize data through toGeneric so typed structs become map[string]any.
|
||||
@@ -45,6 +59,7 @@ func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
// Convert json.Number values to gojq-compatible types.
|
||||
normalized = convertNumbers(normalized)
|
||||
|
||||
var count int
|
||||
iter := code.Run(normalized)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
@@ -52,13 +67,14 @@ func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
break
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
|
||||
return count, errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
|
||||
}
|
||||
if err := writeJqValue(w, v, raw); err != nil {
|
||||
return err
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
|
||||
|
||||
@@ -61,6 +61,10 @@ type Property struct {
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
// Projected marks a response field for the schema-curated default view: the
|
||||
// output projection engine keeps it by default and hides unmarked fields
|
||||
// (recoverable via --full). Carries no security/permission meaning.
|
||||
Projected bool `json:"projected,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
|
||||
177
shortcuts/common/projection.go
Normal file
177
shortcuts/common/projection.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
)
|
||||
|
||||
// ProjectBySchema trims data to the schema-curated default view.
|
||||
//
|
||||
// full==true / props==nil → passthrough (fail-open, never drops information).
|
||||
// Otherwise the input is normalized to canonical JSON values first, then the
|
||||
// core keeps only fields whose Projected==true (a field shows iff p.Projected
|
||||
// || full). It rebuilds maps/slices (never mutates input), skips missing keys
|
||||
// (no null padding), and carries zero business knowledge.
|
||||
//
|
||||
// Normalizing first is what makes this robust by construction: whatever native
|
||||
// Go type a command emits — a typed slice ([]map[string]interface{}), a struct,
|
||||
// a typed map, anything JSON-serializable — collapses to canonical
|
||||
// map[string]interface{} / []interface{} / scalars, so the core's finite switch
|
||||
// is COMPLETE. No command output, present or future, can fall through unprojected.
|
||||
func ProjectBySchema(data interface{}, props *schema.OrderedProps, full bool) interface{} {
|
||||
if full || props == nil {
|
||||
return data
|
||||
}
|
||||
return projectCanonical(canonicalize(data), props)
|
||||
}
|
||||
|
||||
// projectCanonical projects already-canonical JSON data. props==nil is a kept
|
||||
// leaf with no child schema: pass its value through verbatim.
|
||||
func projectCanonical(data interface{}, props *schema.OrderedProps) interface{} {
|
||||
if props == nil {
|
||||
return data
|
||||
}
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
out := map[string]interface{}{}
|
||||
for _, key := range props.Order {
|
||||
p := props.Map[key]
|
||||
if !p.Projected {
|
||||
continue
|
||||
}
|
||||
if val, ok := v[key]; ok {
|
||||
out[key] = projectCanonical(val, childProps(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(v))
|
||||
for i := range v {
|
||||
out[i] = projectCanonical(v[i], props)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
// Canonical scalar (string / float64 / bool / nil): a leaf value, kept verbatim.
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalize converts any JSON-serializable value to canonical JSON values
|
||||
// (map[string]interface{} / []interface{} / scalars) via a marshal round-trip,
|
||||
// decoupling projection from a command's concrete Go types. Fail-open: input
|
||||
// that does not serialize (never the case for command output) is returned as-is.
|
||||
func canonicalize(data interface{}) interface{} {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// childProps returns the schema to recurse with for a field's value. Array
|
||||
// fields keep their element schema in Items.Properties; object fields use
|
||||
// Properties directly. Without this, an array-typed field would recurse with
|
||||
// nil props and pass its elements through unprojected.
|
||||
func childProps(p schema.Property) *schema.OrderedProps {
|
||||
if p.Items != nil && p.Items.Properties != nil {
|
||||
return p.Items.Properties
|
||||
}
|
||||
return p.Properties
|
||||
}
|
||||
|
||||
// droppedFieldNames returns the set of field names that projection removes from
|
||||
// data under props (present in data but not declared projected, at any depth).
|
||||
//
|
||||
// Unlike a static schema scan, it diffs the actual response against the schema:
|
||||
// with positive polarity the hidden fields are simply absent from the
|
||||
// OutputSchema (not marked "false"), so the only way to name a trimmed field is
|
||||
// to see it in the real data and find it missing from the projected set. Drives
|
||||
// the on-demand jq-miss hint. Names only (no values) — never echoes user input
|
||||
// or upstream data.
|
||||
func droppedFieldNames(data interface{}, props *schema.OrderedProps) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
var walk func(d interface{}, p *schema.OrderedProps)
|
||||
walk = func(d interface{}, p *schema.OrderedProps) {
|
||||
switch v := d.(type) {
|
||||
case map[string]interface{}:
|
||||
for key := range v {
|
||||
var pr schema.Property
|
||||
ok := false
|
||||
if p != nil {
|
||||
pr, ok = p.Map[key]
|
||||
}
|
||||
if !ok || !pr.Projected {
|
||||
out[key] = true
|
||||
continue
|
||||
}
|
||||
// Kept field: recurse only when it has a child schema (object /
|
||||
// array element); a projected leaf keeps its whole value, so
|
||||
// nothing inside it is dropped.
|
||||
if cp := childProps(pr); cp != nil {
|
||||
walk(v[key], cp)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range v {
|
||||
walk(e, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(canonicalize(data), props) // canonical input → the two-case walk is complete
|
||||
return out
|
||||
}
|
||||
|
||||
// anyProjected reports whether any field in the tree carries Projected==true.
|
||||
// Used as a guard: a Projectable command whose OutputSchema marks nothing is
|
||||
// treated as pass-through rather than trimming everything away.
|
||||
func anyProjected(props *schema.OrderedProps) bool {
|
||||
if props == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range props.Order {
|
||||
p := props.Map[key]
|
||||
if p.Projected {
|
||||
return true
|
||||
}
|
||||
if anyProjected(p.Properties) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── OutputSchema builders ──
|
||||
//
|
||||
// These let a shortcut declare its OutputSchema inline in Go, shaped to match
|
||||
// the data it emits. A field declared here shows in the default (projected)
|
||||
// view; anything not declared is hidden until --full.
|
||||
|
||||
// KeepFields returns an OrderedProps with each name marked projected as a leaf
|
||||
// field — the common case for a flat group of scalar fields kept by default.
|
||||
func KeepFields(names ...string) *schema.OrderedProps {
|
||||
props := &schema.OrderedProps{}
|
||||
for _, n := range names {
|
||||
props.Set(n, schema.Property{Projected: true})
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// ArrayOf returns a projected array-typed property whose elements are projected
|
||||
// by elem. Use for a default-shown list field: root.Set("chats", ArrayOf(chat)).
|
||||
func ArrayOf(elem *schema.OrderedProps) schema.Property {
|
||||
return schema.Property{Projected: true, Items: &schema.Property{Properties: elem}}
|
||||
}
|
||||
|
||||
// ObjectOf returns a projected nested-object property whose sub-fields are
|
||||
// projected by child. Use for a default-shown object field.
|
||||
func ObjectOf(child *schema.OrderedProps) schema.Property {
|
||||
return schema.Property{Projected: true, Properties: child}
|
||||
}
|
||||
374
shortcuts/common/projection_test.go
Normal file
374
shortcuts/common/projection_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
)
|
||||
|
||||
func props(marked ...string) *schema.OrderedProps {
|
||||
op := &schema.OrderedProps{}
|
||||
set := map[string]bool{}
|
||||
for _, m := range marked {
|
||||
set[m] = true
|
||||
}
|
||||
for _, k := range []string{"name", "avatar", "tenant_key"} {
|
||||
op.Set(k, schema.Property{Type: "string", Projected: set[k]})
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func TestProjectBySchema_PositivePolarity(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g", "avatar": "u", "tenant_key": "t"}
|
||||
out := ProjectBySchema(in, props("name"), false)
|
||||
want := map[string]interface{}{"name": "g"}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("got %v want %v", out, want)
|
||||
}
|
||||
if len(in) != 3 {
|
||||
t.Fatalf("input map mutated: %v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_FailOpen(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g", "avatar": "u"}
|
||||
if got := ProjectBySchema(in, props("name"), true); !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("full=true should passthrough, got %v", got)
|
||||
}
|
||||
if got := ProjectBySchema(in, nil, false); !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("nil props should passthrough, got %v", got)
|
||||
}
|
||||
if got := ProjectBySchema("scalar", props("name"), false); got != "scalar" {
|
||||
t.Fatalf("non-map should passthrough, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_Array(t *testing.T) {
|
||||
in := []interface{}{
|
||||
map[string]interface{}{"name": "a", "avatar": "x"},
|
||||
map[string]interface{}{"name": "b", "avatar": "y"},
|
||||
}
|
||||
out := ProjectBySchema(in, props("name"), false).([]interface{})
|
||||
for i, it := range out {
|
||||
m := it.(map[string]interface{})
|
||||
if _, ok := m["avatar"]; ok {
|
||||
t.Fatalf("elem %d kept avatar", i)
|
||||
}
|
||||
if m["name"] == nil {
|
||||
t.Fatalf("elem %d dropped projected name", i)
|
||||
}
|
||||
}
|
||||
// original input slice must be untouched (no in-place mutation)
|
||||
for i, it := range in {
|
||||
m := it.(map[string]interface{})
|
||||
if _, ok := m["avatar"]; !ok {
|
||||
t.Fatalf("input elem %d was mutated (avatar removed)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_Nested(t *testing.T) {
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
child.Set("avatar", schema.Property{Type: "string"})
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("detail", schema.Property{Type: "object", Projected: true, Properties: child})
|
||||
|
||||
in := map[string]interface{}{"detail": map[string]interface{}{"chat_id": "oc", "avatar": "u"}}
|
||||
out := ProjectBySchema(in, root, false).(map[string]interface{})
|
||||
d := out["detail"].(map[string]interface{})
|
||||
if _, ok := d["avatar"]; ok {
|
||||
t.Fatalf("nested avatar should be dropped")
|
||||
}
|
||||
if d["chat_id"] != "oc" {
|
||||
t.Fatalf("nested chat_id should be kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_ArrayFieldInMap(t *testing.T) {
|
||||
// Mirrors the real list shape: data = {chats: [ {chat_id, avatar}, ... ], page_token}.
|
||||
// The element schema lives in the "chats" field's Items.Properties.
|
||||
elem := &schema.OrderedProps{}
|
||||
elem.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
elem.Set("avatar", schema.Property{Type: "string"}) // full-only
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("chats", schema.Property{
|
||||
Type: "array",
|
||||
Projected: true,
|
||||
Items: &schema.Property{Type: "object", Properties: elem},
|
||||
})
|
||||
root.Set("page_token", schema.Property{Type: "string", Projected: true})
|
||||
root.Set("has_more", schema.Property{Type: "boolean"}) // unmarked → dropped
|
||||
|
||||
in := map[string]interface{}{
|
||||
"chats": []interface{}{map[string]interface{}{"chat_id": "oc", "avatar": "u"}},
|
||||
"page_token": "pt",
|
||||
"has_more": true,
|
||||
}
|
||||
out := ProjectBySchema(in, root, false).(map[string]interface{})
|
||||
if out["page_token"] != "pt" {
|
||||
t.Fatalf("projected pagination field page_token should survive")
|
||||
}
|
||||
if _, ok := out["has_more"]; ok {
|
||||
t.Fatalf("unmarked has_more should be dropped")
|
||||
}
|
||||
chats := out["chats"].([]interface{})
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if c0["chat_id"] != "oc" {
|
||||
t.Fatalf("element projected field chat_id should survive")
|
||||
}
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("element full-only avatar should be dropped (array element must be projected via Items.Properties)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_MissingFieldSkipped(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g"} // avatar missing
|
||||
out := ProjectBySchema(in, props("name", "avatar"), false).(map[string]interface{})
|
||||
if _, ok := out["avatar"]; ok {
|
||||
t.Fatalf("missing field should not be added as null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroppedFieldNames(t *testing.T) {
|
||||
// props("name") declares only "name" projected; avatar/tenant_key are absent
|
||||
// from the schema (positive polarity), so the engine trims them. droppedFieldNames
|
||||
// must find them by diffing the real data against the schema.
|
||||
op := props("name")
|
||||
data := map[string]interface{}{"name": "g", "avatar": "x", "tenant_key": "t"}
|
||||
got := droppedFieldNames(data, op)
|
||||
|
||||
if !got["avatar"] || !got["tenant_key"] {
|
||||
t.Errorf("avatar/tenant_key should be reported dropped, got %v", got)
|
||||
}
|
||||
if got["name"] {
|
||||
t.Error("projected field name must not be reported dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroppedFieldNames_Nested(t *testing.T) {
|
||||
// root keeps "detail" (object) with only "chat_id" projected; the response's
|
||||
// detail.avatar and root-level total are not declared, so both are dropped.
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("detail", schema.Property{Type: "object", Projected: true, Properties: child})
|
||||
|
||||
data := map[string]interface{}{
|
||||
"detail": map[string]interface{}{"chat_id": "oc_1", "avatar": "x"},
|
||||
"total": 5,
|
||||
}
|
||||
got := droppedFieldNames(data, root)
|
||||
|
||||
if !got["avatar"] {
|
||||
t.Error("nested detail.avatar should be reported dropped")
|
||||
}
|
||||
if !got["total"] {
|
||||
t.Error("undeclared root-level total should be reported dropped")
|
||||
}
|
||||
if got["detail"] || got["chat_id"] {
|
||||
t.Errorf("projected detail/chat_id must not be dropped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProjected(t *testing.T) {
|
||||
none := &schema.OrderedProps{}
|
||||
none.Set("a", schema.Property{})
|
||||
if anyProjected(none) {
|
||||
t.Fatalf("no marks should report false")
|
||||
}
|
||||
some := &schema.OrderedProps{}
|
||||
some.Set("a", schema.Property{})
|
||||
some.Set("b", schema.Property{Projected: true})
|
||||
if !anyProjected(some) {
|
||||
t.Fatalf("a mark should report true")
|
||||
}
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("c", schema.Property{Projected: true})
|
||||
nested := &schema.OrderedProps{}
|
||||
nested.Set("obj", schema.Property{Type: "object", Properties: child})
|
||||
if !anyProjected(nested) {
|
||||
t.Fatalf("nested mark should report true")
|
||||
}
|
||||
if anyProjected(nil) {
|
||||
t.Fatalf("nil should report false")
|
||||
}
|
||||
}
|
||||
|
||||
// ── OutputSchema builder tests ──
|
||||
|
||||
// TestKeepFields verifies KeepFields marks every named field projected, as a
|
||||
// leaf (no Items/Properties), preserving declaration order.
|
||||
func TestKeepFields(t *testing.T) {
|
||||
op := KeepFields("chat_id", "name", "owner_id")
|
||||
if want := []string{"chat_id", "name", "owner_id"}; !reflect.DeepEqual(op.Order, want) {
|
||||
t.Fatalf("Order = %v, want %v", op.Order, want)
|
||||
}
|
||||
for _, k := range op.Order {
|
||||
p := op.Map[k]
|
||||
if !p.Projected {
|
||||
t.Errorf("field %q should be projected", k)
|
||||
}
|
||||
if p.Items != nil || p.Properties != nil {
|
||||
t.Errorf("field %q should be a leaf (no Items/Properties)", k)
|
||||
}
|
||||
}
|
||||
if len(KeepFields().Order) != 0 {
|
||||
t.Fatalf("KeepFields() with no names should yield empty props")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayOf verifies ArrayOf returns a projected property whose elements are
|
||||
// projected by the supplied element schema (carried in Items.Properties), and
|
||||
// that childProps recurses into that element schema.
|
||||
func TestArrayOf(t *testing.T) {
|
||||
elem := KeepFields("chat_id", "name")
|
||||
p := ArrayOf(elem)
|
||||
if !p.Projected {
|
||||
t.Fatalf("ArrayOf should mark the array field projected")
|
||||
}
|
||||
if p.Items == nil || p.Items.Properties != elem {
|
||||
t.Fatalf("ArrayOf should carry the element schema in Items.Properties")
|
||||
}
|
||||
if childProps(p) != elem {
|
||||
t.Fatalf("childProps should recurse into the array element schema")
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectOf verifies ObjectOf returns a projected object property whose
|
||||
// sub-fields are projected by the supplied child schema (carried in Properties),
|
||||
// and that childProps recurses into that child schema.
|
||||
func TestObjectOf(t *testing.T) {
|
||||
child := KeepFields("chat_id", "chat_mode")
|
||||
p := ObjectOf(child)
|
||||
if !p.Projected {
|
||||
t.Fatalf("ObjectOf should mark the object field projected")
|
||||
}
|
||||
if p.Properties != child {
|
||||
t.Fatalf("ObjectOf should carry the child schema in Properties")
|
||||
}
|
||||
if p.Items != nil {
|
||||
t.Fatalf("ObjectOf should not set Items")
|
||||
}
|
||||
if childProps(p) != child {
|
||||
t.Fatalf("childProps should recurse into the object child schema")
|
||||
}
|
||||
}
|
||||
|
||||
// chatListShapeSchema mirrors the +chat-list OutputSchema built with the
|
||||
// KeepFields/ArrayOf builders: root marks pagination + the chats wrapper; each
|
||||
// chat keeps a curated field set while avatar stays full-only.
|
||||
func chatListShapeSchema() *schema.OrderedProps {
|
||||
chat := KeepFields("chat_id", "name", "owner_id")
|
||||
root := KeepFields("has_more", "page_token")
|
||||
root.Set("chats", ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// TestProjectBySchema_ChatListShape is the integration check the task asks for:
|
||||
// against a chat-list-shaped map, projected fields (wrapper, pagination, the
|
||||
// curated chat fields) survive, unmarked fields (top-level total, per-chat
|
||||
// avatar) are trimmed, and --full passes the whole envelope through verbatim.
|
||||
func TestProjectBySchema_ChatListShape(t *testing.T) {
|
||||
envelope := func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"chats": []interface{}{
|
||||
map[string]interface{}{
|
||||
"chat_id": "oc_1",
|
||||
"name": "Team",
|
||||
"owner_id": "ou_owner",
|
||||
"avatar": "http://img/1.png", // full-only
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "pt_next",
|
||||
"total": 1, // unmarked top-level key → trimmed by default
|
||||
}
|
||||
}
|
||||
|
||||
// Default (projected) view.
|
||||
out := ProjectBySchema(envelope(), chatListShapeSchema(), false).(map[string]interface{})
|
||||
if out["has_more"] != true || out["page_token"] != "pt_next" {
|
||||
t.Fatalf("projected pagination fields should survive, got %v", out)
|
||||
}
|
||||
if _, ok := out["total"]; ok {
|
||||
t.Fatalf("unmarked top-level total should be trimmed")
|
||||
}
|
||||
chats := out["chats"].([]interface{})
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
for _, k := range []string{"chat_id", "name", "owner_id"} {
|
||||
if _, ok := c0[k]; !ok {
|
||||
t.Fatalf("projected chat field %q should survive", k)
|
||||
}
|
||||
}
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("full-only chat field avatar should be trimmed by default")
|
||||
}
|
||||
|
||||
// --full passthrough: identical to the untouched envelope.
|
||||
full := ProjectBySchema(envelope(), chatListShapeSchema(), true)
|
||||
if !reflect.DeepEqual(full, envelope()) {
|
||||
t.Fatalf("--full should pass the whole envelope through verbatim, got %v", full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectBySchema_TypedMapSlice guards the []map[string]interface{} case:
|
||||
// +chat-list re-collects API items into a typed []map[string]interface{} (not
|
||||
// []interface{}). Before the fix that slice fell through to the default branch
|
||||
// and was returned unprojected — avatar/tenant_key leaked into the default view.
|
||||
// Regression test for that real E2E failure.
|
||||
func TestProjectBySchema_TypedMapSlice(t *testing.T) {
|
||||
envelope := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{ // typed slice, NOT []interface{}
|
||||
{"chat_id": "oc_1", "name": "Team", "owner_id": "ou_o", "avatar": "http://img"},
|
||||
},
|
||||
"has_more": true,
|
||||
}
|
||||
out := ProjectBySchema(envelope, chatListShapeSchema(), false).(map[string]interface{})
|
||||
chats, ok := out["chats"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("typed []map slice should be recursed into []interface{}, got %T", out["chats"])
|
||||
}
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("full-only avatar must be trimmed from a []map[string]interface{} element, got %v", c0)
|
||||
}
|
||||
if c0["chat_id"] != "oc_1" || c0["name"] != "Team" {
|
||||
t.Fatalf("projected fields must survive, got %v", c0)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectBySchema_AnyGoType is the robustness proof: a command may emit any
|
||||
// JSON-serializable Go type — here a []struct, a shape the engine never special-
|
||||
// cases. Normalization collapses it to canonical JSON, so projection still trims
|
||||
// it. This is what makes the engine immune to "the next weird box".
|
||||
func TestProjectBySchema_AnyGoType(t *testing.T) {
|
||||
type chatStruct struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"` // not declared projected → must be trimmed
|
||||
}
|
||||
root := KeepFields("has_more")
|
||||
root.Set("chats", ArrayOf(KeepFields("chat_id", "name")))
|
||||
|
||||
data := map[string]interface{}{
|
||||
"has_more": true,
|
||||
"chats": []chatStruct{{ChatID: "oc_1", Name: "Team", Avatar: "http://img"}},
|
||||
}
|
||||
out := ProjectBySchema(data, root, false).(map[string]interface{})
|
||||
chats, ok := out["chats"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("a []struct must normalize + recurse into []interface{}, got %T", out["chats"])
|
||||
}
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("avatar must be trimmed even from a []struct, got %v", c0)
|
||||
}
|
||||
if c0["chat_id"] != "oc_1" || c0["name"] != "Team" {
|
||||
t.Fatalf("projected fields must survive, got %v", c0)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -50,6 +52,8 @@ type RuntimeContext struct {
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
projectable bool // set when Shortcut.Projectable is true
|
||||
outputProps *schema.OrderedProps // command OutputSchema props when it marks fields; nil = pass-through
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -694,19 +698,49 @@ func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok boo
|
||||
return
|
||||
}
|
||||
|
||||
var droppedNames map[string]bool
|
||||
if ctx.projectable && ctx.outputProps != nil {
|
||||
if !ctx.Bool("full") {
|
||||
// Capture what projection trims from the real response (before the
|
||||
// reassign below) so the jq-miss hint can name the specific field.
|
||||
droppedNames = droppedFieldNames(data, ctx.outputProps)
|
||||
}
|
||||
data = ProjectBySchema(data, ctx.outputProps, ctx.Bool("full"))
|
||||
}
|
||||
|
||||
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
|
||||
if ctx.JqExpr != "" {
|
||||
filter := output.JqFilter
|
||||
var err error
|
||||
if raw {
|
||||
filter = output.JqFilterRaw
|
||||
_, err = output.JqFilterRawCount(ctx.IO().Out, env, ctx.JqExpr)
|
||||
} else {
|
||||
_, err = output.JqFilterCount(ctx.IO().Out, env, ctx.JqExpr)
|
||||
}
|
||||
if err := filter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
if err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
||||
return
|
||||
}
|
||||
// Fire whenever the agent's jq references a field projection dropped —
|
||||
// regardless of result count. A jq path to a trimmed field yields null
|
||||
// (one result), not an empty result, so a count==0 gate would miss it.
|
||||
if len(droppedNames) > 0 {
|
||||
keys := make([]string, 0, len(droppedNames))
|
||||
for n := range droppedNames {
|
||||
keys = append(keys, n)
|
||||
}
|
||||
sort.Strings(keys) // deterministic
|
||||
for _, name := range keys {
|
||||
if strings.Contains(ctx.JqExpr, name) {
|
||||
fmt.Fprintf(ctx.IO().ErrOut,
|
||||
"note: field `%s` exists but is full-only in this command; re-run with --full (or --full --jq <path> for just that field)\n", name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -778,6 +812,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
if !formatOK {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
}
|
||||
// Projection applies to non-JSON formats too, so pretty/table/csv match
|
||||
// --json (all formats show the same curated view; --full returns all).
|
||||
if ctx.projectable && ctx.outputProps != nil {
|
||||
data = ProjectBySchema(data, ctx.outputProps, ctx.Bool("full"))
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
}
|
||||
@@ -943,6 +982,15 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Projectable {
|
||||
rctx.projectable = true
|
||||
if anyProjected(s.OutputSchema) {
|
||||
rctx.outputProps = s.OutputSchema
|
||||
}
|
||||
}
|
||||
if s.NoFullViewHint != "" && rctx.Bool("full") {
|
||||
fmt.Fprintln(rctx.IO().ErrOut, s.NoFullViewHint)
|
||||
}
|
||||
if s.Validate != nil {
|
||||
if err := s.Validate(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
@@ -1238,6 +1286,15 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
if s.Projectable && cmd.Flags().Lookup("full") == nil {
|
||||
cmd.Flags().Bool("full", false,
|
||||
"return the complete upstream payload instead of the schema-curated default view; "+
|
||||
"to fetch one hidden field, prefer --jq <path> (--full returns everything)")
|
||||
}
|
||||
if s.NoFullViewHint != "" && cmd.Flags().Lookup("full") == nil {
|
||||
cmd.Flags().Bool("full", false, "(this command has no full view — see the note printed when used)")
|
||||
_ = cmd.Flags().MarkHidden("full")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package common
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,20 @@ type Shortcut struct {
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
// Projectable opts the command into framework output projection: the framework
|
||||
// auto-injects a --full flag and, after Execute, trims the envelope data by the
|
||||
// command's OutputSchema. No-op/fail-open when OutputSchema is nil or marks nothing.
|
||||
Projectable bool
|
||||
// OutputSchema describes the data this command emits, with projected marks
|
||||
// selecting the default view (build it with KeepFields/ArrayOf/ObjectOf). Consulted
|
||||
// only when Projectable is true; lives on the command, no registry dependency.
|
||||
OutputSchema *schema.OrderedProps
|
||||
// NoFullViewHint, when non-empty, registers a hidden --full flag for a command
|
||||
// that has no full view (e.g. message commands whose output is already a curated
|
||||
// layer). Passing --full prints this redirect to stderr and otherwise runs
|
||||
// normally — turning a flailing "unknown flag" retry into one clear path.
|
||||
NoFullViewHint string
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
|
||||
@@ -180,7 +180,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
"message_id": messageId,
|
||||
"msg_type": msgType,
|
||||
"content": content,
|
||||
"sender": m["sender"],
|
||||
"sender": projectSender(m["sender"]),
|
||||
"create_time": common.FormatTime(m["create_time"]),
|
||||
"deleted": deleted,
|
||||
"updated": updated,
|
||||
@@ -417,6 +417,22 @@ func extractMentionOpenId(id interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectSender tightens the message sender to {id, sender_type, name}. It keeps
|
||||
// sender_type (real signal: user vs app coexist) and drops the constant id_type
|
||||
// (always open_id) and tenant_key (constant within a tenant). name is injected
|
||||
// later by the contact-name resolver.
|
||||
func projectSender(v interface{}) interface{} {
|
||||
s, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
out := map[string]interface{}{"id": s["id"]}
|
||||
if st, ok := s["sender_type"]; ok {
|
||||
out["sender_type"] = st
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateContent truncates a string for table display.
|
||||
func TruncateContent(s string, max int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
|
||||
@@ -113,25 +113,10 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
|
||||
}
|
||||
}
|
||||
|
||||
// Collect sender IDs still missing a name
|
||||
seen := make(map[string]bool)
|
||||
var missingIDs []string
|
||||
for _, msg := range messages {
|
||||
sender, ok := msg["sender"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
senderType, _ := sender["sender_type"].(string)
|
||||
if senderType != "user" {
|
||||
continue
|
||||
}
|
||||
id, _ := sender["id"].(string)
|
||||
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
// Collect sender IDs still missing a name, identified by ou_ prefix.
|
||||
// Decoupled from sender_type: projectSender runs before resolution and strips
|
||||
// noise fields; keying on the ou_ prefix is order-independent and robust.
|
||||
missingIDs := unresolvedUserSenderIDs(messages, nameMap)
|
||||
if len(missingIDs) == 0 {
|
||||
return nameMap
|
||||
}
|
||||
@@ -215,6 +200,24 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name
|
||||
}
|
||||
}
|
||||
|
||||
// unresolvedUserSenderIDs collects distinct ou_-prefixed (user) sender ids that
|
||||
// still need a name. Decoupled from sender_type on purpose: tightening runs
|
||||
// before resolution, so keying on the ou_ prefix is order-independent and robust.
|
||||
func unresolvedUserSenderIDs(messages []map[string]interface{}, nameMap map[string]string) []string {
|
||||
var ids []string
|
||||
seen := map[string]bool{}
|
||||
for _, msg := range messages {
|
||||
s, _ := msg["sender"].(map[string]interface{})
|
||||
id, _ := s["id"].(string)
|
||||
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// AttachSenderNames enriches message sender objects with resolved display names.
|
||||
// Senders whose name could not be resolved are left unchanged (id is preserved).
|
||||
func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -39,13 +40,15 @@ func writeBotStripP2pWarning(errOut io.Writer) {
|
||||
// list groups the current user/bot is a member of. Supports sort order,
|
||||
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
|
||||
var ImChatList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: chatListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
@@ -197,6 +200,20 @@ var ImChatList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// chatListOutputSchema declares the default (projected) view for +chat-list:
|
||||
// the chat object's useful fields + pagination + filter/notices metadata. The
|
||||
// chat's avatar / tenant_key / owner_id_type are left unmarked, so they are
|
||||
// hidden by default and recoverable via --full (§6.7 initial setting).
|
||||
func chatListOutputSchema() *schema.OrderedProps {
|
||||
chat := common.KeepFields(
|
||||
"chat_id", "name", "description", "owner_id", "external",
|
||||
"chat_status", "chat_mode", "p2p_target_type", "p2p_target_id",
|
||||
)
|
||||
root := common.KeepFields("has_more", "page_token", "filter", "notices")
|
||||
root.Set("chats", common.ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
|
||||
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
|
||||
// and repeated --types p2p --types group arrive here as a 2-element []string,
|
||||
|
||||
@@ -17,16 +17,22 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// imMessageNoFullHint redirects an agent that reflexively passes --full to a
|
||||
// message command (which has no full view) to the right path instead of leaving
|
||||
// it to flail on an "unknown flag" error.
|
||||
const imMessageNoFullHint = "note: messages have no --full view; sender is intentionally minimal ({id, sender_type, name}). Do not retry with --full — for fuller sender details resolve the id via the contact domain (e.g. `lark-cli contact +search-user`, or contact user get)."
|
||||
|
||||
var ImChatMessageList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-messages-list",
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-messages-list",
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -21,13 +22,15 @@ import (
|
||||
// member/type filters, sort order, pagination, and (user identity only) the
|
||||
// --exclude-muted client-side mute filter.
|
||||
var ImChatSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: chatSearchOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
@@ -207,6 +210,22 @@ var ImChatSearch = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// chatSearchOutputSchema declares the default (projected) view for +chat-search.
|
||||
// Mirrors +chat-list's chat object plus create_time (the search meta_data carries
|
||||
// it), and marks the wrapper key (chats), pagination (has_more/page_token), and
|
||||
// the total count so they survive trimming. The chat's avatar / tenant_key /
|
||||
// owner_id_type stay unmarked → hidden by default, recoverable via --full.
|
||||
func chatSearchOutputSchema() *schema.OrderedProps {
|
||||
chat := common.KeepFields(
|
||||
"chat_id", "name", "description", "owner_id", "external",
|
||||
"chat_status", "chat_mode", "p2p_target_type", "p2p_target_id",
|
||||
"create_time",
|
||||
)
|
||||
root := common.KeepFields("has_more", "page_token", "total", "filter")
|
||||
root.Set("chats", common.ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -25,13 +26,15 @@ const feedGroupListPath = "/open-apis/im/v1/groups"
|
||||
// which follows only one array field and silently drops the other list's later
|
||||
// pages; this shortcut paginates the dual-list response itself.
|
||||
var ImFeedGroupList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-group-list",
|
||||
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+feed-group-list",
|
||||
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: feedGroupListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -71,6 +74,18 @@ var ImFeedGroupList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// feedGroupListOutputSchema declares the default (projected) view for
|
||||
// +feed-group-list. Each group keeps group_id/name/type; its rules stay
|
||||
// full-only. Both list wrappers (groups, deleted_groups) and pagination
|
||||
// (has_more/page_token) are marked so they survive trimming.
|
||||
func feedGroupListOutputSchema() *schema.OrderedProps {
|
||||
group := common.KeepFields("group_id", "name", "type")
|
||||
root := common.KeepFields("has_more", "page_token")
|
||||
root.Set("groups", common.ArrayOf(group))
|
||||
root.Set("deleted_groups", common.ArrayOf(group))
|
||||
return root
|
||||
}
|
||||
|
||||
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -30,6 +31,8 @@ var ImFeedShortcutList = common.Shortcut{
|
||||
ConditionalUserScopes: []string{chatBatchQueryScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: feedShortcutListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-token",
|
||||
Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
|
||||
@@ -69,6 +72,24 @@ var ImFeedShortcutList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// feedShortcutListOutputSchema declares the default (projected) view for
|
||||
// +feed-shortcut-list. Each entry keeps feed_card_id + type, and the enriched
|
||||
// `detail` (full chat object from im.chats.batch_query) is narrowed to
|
||||
// chat_id/name/chat_mode — its avatar / tenant_key / owner_id* / description /
|
||||
// external stay full-only. The wrapper key (shortcuts), pagination
|
||||
// (has_more/page_token), and the data-level _notice the command may emit on
|
||||
// enrichment failure are all marked so they survive trimming.
|
||||
func feedShortcutListOutputSchema() *schema.OrderedProps {
|
||||
detail := common.KeepFields("chat_id", "name", "chat_mode")
|
||||
|
||||
item := common.KeepFields("feed_card_id", "type")
|
||||
item.Set("detail", common.ObjectOf(detail))
|
||||
|
||||
root := common.KeepFields("has_more", "page_token", "_notice")
|
||||
root.Set("shortcuts", common.ArrayOf(item))
|
||||
return root
|
||||
}
|
||||
|
||||
// feedShortcutListQuery omits the page_token key entirely when the token is
|
||||
// empty, so the server treats the call as a first-page request.
|
||||
func feedShortcutListQuery(token string) larkcore.QueryParams {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -17,13 +18,15 @@ import (
|
||||
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
|
||||
// Feed-type thread entries are auto-enriched with message content.
|
||||
var ImFlagList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: flagListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -70,6 +73,27 @@ var ImFlagList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// flagListOutputSchema declares the default (projected) view for +flag-list.
|
||||
// Each bookmark item keeps its identity/type/timestamps; the enriched feed-thread
|
||||
// `message` is narrowed to message_id + msg_type + body.content (the rest of the
|
||||
// raw mget message — sender, mentions, chat_id, etc. — stays full-only). The two
|
||||
// list wrappers (flag_items, delete_flag_items), the inline messages array, and
|
||||
// pagination (has_more/page_token) are all marked so they survive trimming.
|
||||
func flagListOutputSchema() *schema.OrderedProps {
|
||||
body := common.KeepFields("content")
|
||||
message := common.KeepFields("message_id", "msg_type")
|
||||
message.Set("body", common.ObjectOf(body))
|
||||
|
||||
item := common.KeepFields("item_id", "flag_type", "item_type", "create_time", "update_time")
|
||||
item.Set("message", common.ObjectOf(message))
|
||||
|
||||
root := common.KeepFields("has_more", "page_token")
|
||||
root.Set("flag_items", common.ArrayOf(item))
|
||||
root.Set("delete_flag_items", common.ArrayOf(item))
|
||||
root.Set("messages", common.ArrayOf(message))
|
||||
return root
|
||||
}
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
|
||||
@@ -18,15 +18,16 @@ import (
|
||||
const maxMGetMessageIDs = 50
|
||||
|
||||
var ImMessagesMGet = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-mget",
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+messages-mget",
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
|
||||
@@ -26,13 +26,14 @@ const (
|
||||
)
|
||||
|
||||
var ImMessagesSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "chat-id", Desc: "limit to chat IDs, comma-separated"},
|
||||
|
||||
@@ -20,15 +20,16 @@ import (
|
||||
const threadsMessagesMaxPageSize = 500
|
||||
|
||||
var ImThreadsMessagesList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+threads-messages-list",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+threads-messages-list",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
|
||||
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
426
shortcuts/slides/slides_replace_pages.go
Normal file
426
shortcuts/slides/slides_replace_pages.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
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: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return invalidSlideXMLStructureError("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return invalidSlideXMLStructureError("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return invalidSlideXMLStructureError("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return invalidSlideXMLStructureError("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var requestOrder []string
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
var deleteQuery map[string][]string
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
deleteQuery = req.URL.Query()
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
|
||||
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
|
||||
t.Fatalf("delete slide_id = %#v, want old2", got)
|
||||
}
|
||||
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
|
||||
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
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"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
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},
|
||||
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,21 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestReplaceSlideDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
|
||||
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
|
||||
// onto the root before sending. The backend returns 3350001 otherwise.
|
||||
|
||||
@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,11 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
144
shortcuts/slides/slides_xml_get.go
Normal file
144
shortcuts/slides/slides_xml_get.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// 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: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
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("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
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())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
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("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
165
shortcuts/slides/slides_xml_get_test.go
Normal file
165
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", validationErr.Param)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,15 @@ lark-cli im +chat-list --as user --types p2p
|
||||
| `p2p_target_type` | Peer type, e.g., `user` |
|
||||
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: only the fields above. Verbose / low-value fields on each chat (`avatar`, `tenant_key`, `owner_id_type`) are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (all hidden fields included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.chats[].avatar'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.chats[].avatar'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Including p2p single chats
|
||||
|
||||
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
|
||||
|
||||
@@ -104,6 +104,8 @@ Each message contains:
|
||||
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
|
||||
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
|
||||
|
||||
@@ -72,6 +72,15 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: only the fields above (plus `create_time`). Verbose / low-value fields on each chat (`avatar`, `tenant_key`, `owner_id_type`) are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (all hidden fields included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.chats[].avatar'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.chats[].avatar'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
@@ -58,6 +58,15 @@ JSON keeps the raw envelope; with `--page-all` both lists are returned fully mer
|
||||
|
||||
> `page_size` counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts. Pagination is governed solely by `has_more`.
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: each group keeps `group_id`, `name`, and `type`. The `rules` object (shown in the example above) is **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (`rules` included).
|
||||
- Need `rules`? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.groups[].rules'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.groups[].rules'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## See also
|
||||
|
||||
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance
|
||||
|
||||
@@ -96,6 +96,15 @@ The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in;
|
||||
- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed.
|
||||
- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich".
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**. The `detail` object is narrowed to `chat_id`, `name`, and `chat_mode`; its other chat fields (`avatar`, `tenant_key`, `owner_id*`, `description`, `external`, `p2p_target_*`) shown in the example above are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (full `detail` chat objects included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.shortcuts[].detail.p2p_target_id'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.shortcuts[].detail.p2p_target_id'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scope: `im:feed.shortcut:read`
|
||||
|
||||
@@ -64,6 +64,15 @@ Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched
|
||||
|
||||
- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids <item_id>`.
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**. The enriched feed-thread `message` on each item is narrowed to `message_id`, `msg_type`, and `body.content`; the rest of the raw mget message (`sender`, `mentions`, `chat_id`, etc.) is **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (full message objects included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.flag_items[].message.sender'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.flag_items[].message.sender'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Response Example (Sanitized)
|
||||
|
||||
```json
|
||||
|
||||
@@ -51,6 +51,8 @@ Each message contains:
|
||||
| `sender` | Sender information (includes `name`) |
|
||||
| `content` | Message content |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Fetch the full content of a specific message
|
||||
|
||||
@@ -130,6 +130,8 @@ Each message in JSON output contains:
|
||||
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
|
||||
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
### 4. Pagination behavior
|
||||
|
||||
- Default behavior is still **single-page**.
|
||||
|
||||
@@ -72,6 +72,10 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
|
||||
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--order desc --page-size 1` |
|
||||
|
||||
### 5. Sender is minimal; no `--full` view
|
||||
|
||||
The `sender` sub-object on each reply is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Expand a thread discovered in group messages
|
||||
|
||||
@@ -121,6 +121,10 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 读命令的精选视图与 `--full`
|
||||
|
||||
不少 lark-cli 读命令默认返回**精选视图**,把冗长/低价值字段(如 `avatar`、`tenant_key`)裁掉省上下文——是有意为之,不是字段不存在。需要的字段不在默认输出里时:别据此断言“没这个字段”,也别转头猜别的命令或 meta API——给**同一条命令加 `--full`** 取回完整 payload(只取一个字段就 `--full --jq <path>`)。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
|
||||
@@ -15,7 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
@@ -47,7 +47,7 @@ metadata:
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -82,7 +82,7 @@ lark-cli auth login --domain slides
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
@@ -268,6 +268,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
@@ -286,7 +287,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 编辑已有 PPT:读-改-写闭环
|
||||
|
||||
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。
|
||||
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` |
|
||||
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
|
||||
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 |
|
||||
| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old,不生成新 Slides 链接 |
|
||||
|
||||
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -136,6 +137,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
|
||||
## 相关文档
|
||||
|
||||
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
|
||||
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
|
||||
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`)
|
||||
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
|
||||
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token
|
||||
|
||||
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# slides +replace-pages(多页整页重建)
|
||||
|
||||
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
|
||||
|
||||
> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages \
|
||||
--as user \
|
||||
--presentation <slides_url_or_xml_presentation_id> \
|
||||
--pages @pages.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
|
||||
| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` |
|
||||
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
|
||||
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
|
||||
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
|
||||
|
||||
## pages.json
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slide_id": "slide_short_id_1",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_short_id_2",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 每项必须提供 `slide_id`;不支持 `slide_number`。
|
||||
- `content` 必须是完整 `<slide>...</slide>` XML。
|
||||
- 同一批次不能重复 `slide_id`。
|
||||
- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。
|
||||
|
||||
## Dry Run
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages --as user \
|
||||
--presentation "$PID" \
|
||||
--pages @pages.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
|
||||
|
||||
## 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "xxx",
|
||||
"pages_count": 2,
|
||||
"status": "completed",
|
||||
"summary": {
|
||||
"replaced": 2,
|
||||
"failed": 0,
|
||||
"total": 2
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"old_slide_id": "old3",
|
||||
"new_slide_id": "new3",
|
||||
"status": "replaced"
|
||||
}
|
||||
],
|
||||
"revision_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。
|
||||
|
||||
`status` 可能为:
|
||||
|
||||
- `replaced`:新页创建成功,旧页删除成功。
|
||||
- `create_failed`:新页创建失败,旧页保留。
|
||||
- `delete_failed`:新页已创建,但旧页删除失败。
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
Reference in New Issue
Block a user