Skip to content

Commit aa3bbcb

Browse files
committed
feat(slides): add image upload via +media-upload and @path placeholders in +create
Adds a `slides +media-upload` shortcut that uploads a local image to a presentation and returns its `file_token`, and extends `slides +create --slides` to auto-resolve `<img src="@./path">` placeholders by uploading the referenced files and rewriting `src` to the resulting `file_token` before creating slides. Also expands the lark-slides skill docs to cover the full image flow: discoverability of the upload path, image crop behavior, layout templates, how to add an image to an existing slide page (whole-page replacement), and an explicit warning that `before_slide_id` belongs in `--data` body — putting it in `--params` silently forwards it as an unknown query param and the new slide ends up appended to the end instead of inserted at the requested position.
1 parent 88fd3bd commit aa3bbcb

14 files changed

Lines changed: 1434 additions & 7 deletions

shortcuts/slides/helpers.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package slides
5+
6+
import (
7+
"fmt"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/larksuite/cli/internal/output"
12+
"github.com/larksuite/cli/shortcuts/common"
13+
)
14+
15+
// presentationRef holds a parsed --presentation input.
16+
//
17+
// Slides shortcuts accept three input shapes:
18+
// - a raw xml_presentation_id token
19+
// - a slides URL like https://<host>/slides/<token>
20+
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
21+
type presentationRef struct {
22+
Kind string // "slides" | "wiki"
23+
Token string
24+
}
25+
26+
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
27+
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
28+
// obtain the real xml_presentation_id and verify obj_type=slides.
29+
func parsePresentationRef(input string) (presentationRef, error) {
30+
raw := strings.TrimSpace(input)
31+
if raw == "" {
32+
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
33+
}
34+
if token, ok := extractRefToken(raw, "/slides/"); ok {
35+
return presentationRef{Kind: "slides", Token: token}, nil
36+
}
37+
if token, ok := extractRefToken(raw, "/wiki/"); ok {
38+
return presentationRef{Kind: "wiki", Token: token}, nil
39+
}
40+
if strings.Contains(raw, "://") {
41+
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
42+
}
43+
if strings.ContainsAny(raw, "/?#") {
44+
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
45+
}
46+
return presentationRef{Kind: "slides", Token: raw}, nil
47+
}
48+
49+
func extractRefToken(raw, marker string) (string, bool) {
50+
idx := strings.Index(raw, marker)
51+
if idx < 0 {
52+
return "", false
53+
}
54+
token := raw[idx+len(marker):]
55+
if end := strings.IndexAny(token, "/?#"); end >= 0 {
56+
token = token[:end]
57+
}
58+
token = strings.TrimSpace(token)
59+
if token == "" {
60+
return "", false
61+
}
62+
return token, true
63+
}
64+
65+
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
66+
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
67+
// must resolve to obj_type=slides.
68+
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
69+
switch ref.Kind {
70+
case "slides":
71+
return ref.Token, nil
72+
case "wiki":
73+
data, err := runtime.CallAPI(
74+
"GET",
75+
"/open-apis/wiki/v2/spaces/get_node",
76+
map[string]interface{}{"token": ref.Token},
77+
nil,
78+
)
79+
if err != nil {
80+
return "", err
81+
}
82+
node := common.GetMap(data, "node")
83+
objType := common.GetString(node, "obj_type")
84+
objToken := common.GetString(node, "obj_token")
85+
if objType == "" || objToken == "" {
86+
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
87+
}
88+
if objType != "slides" {
89+
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
90+
}
91+
return objToken, nil
92+
default:
93+
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
94+
}
95+
}
96+
97+
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
98+
// The "@" prefix is the magic marker for "this is a local file path; upload it and
99+
// replace with file_token".
100+
//
101+
// Match groups:
102+
//
103+
// 1: opening quote character (so we can replace symmetrically)
104+
// 2: the path string (everything inside the quotes after the leading @)
105+
//
106+
// We deliberately scope to <img ... src="@..."> rather than any src= so other
107+
// schema elements (like icon/iconType) aren't accidentally rewritten.
108+
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc=(["'])@([^"']+)(["'])`)
109+
110+
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
111+
// referenced via <img src="@path"> in the given slide XML strings.
112+
//
113+
// Order is preserved (first occurrence wins) so dry-run / progress messages are
114+
// stable across runs.
115+
func extractImagePlaceholderPaths(slideXMLs []string) []string {
116+
var paths []string
117+
seen := map[string]bool{}
118+
for _, xml := range slideXMLs {
119+
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
120+
for _, m := range matches {
121+
path := strings.TrimSpace(m[2])
122+
if path == "" || seen[path] {
123+
continue
124+
}
125+
seen[path] = true
126+
paths = append(paths, path)
127+
}
128+
}
129+
return paths
130+
}
131+
132+
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
133+
// XML by looking up each path in tokens. Paths missing from the map are left
134+
// untouched (callers should ensure the map is complete).
135+
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
136+
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
137+
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
138+
if len(sub) < 4 {
139+
return match
140+
}
141+
quote, path, closeQuote := sub[1], sub[2], sub[3]
142+
token, ok := tokens[strings.TrimSpace(path)]
143+
if !ok {
144+
return match
145+
}
146+
// Preserve everything before src= by replacing only the src=... segment.
147+
// Using strings.Replace on the matched substring keeps surrounding attrs intact.
148+
oldSrc := fmt.Sprintf("src=%s@%s%s", quote, path, closeQuote)
149+
newSrc := fmt.Sprintf("src=%s%s%s", quote, token, closeQuote)
150+
return strings.Replace(match, oldSrc, newSrc, 1)
151+
})
152+
}

shortcuts/slides/helpers_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package slides
5+
6+
import (
7+
"reflect"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestParsePresentationRef(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
input string
18+
wantKind string
19+
wantToken string
20+
wantErr string
21+
}{
22+
{name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"},
23+
{name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"},
24+
{name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"},
25+
{name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"},
26+
{name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"},
27+
{name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"},
28+
{name: "empty", input: "", wantErr: "cannot be empty"},
29+
{name: "blank", input: " ", wantErr: "cannot be empty"},
30+
{name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"},
31+
{name: "unsupported path", input: "foo/bar", wantErr: "unsupported"},
32+
}
33+
34+
for _, tt := range tests {
35+
t.Run(tt.name, func(t *testing.T) {
36+
t.Parallel()
37+
got, err := parsePresentationRef(tt.input)
38+
if tt.wantErr != "" {
39+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
40+
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
41+
}
42+
return
43+
}
44+
if err != nil {
45+
t.Fatalf("unexpected error: %v", err)
46+
}
47+
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
48+
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
49+
}
50+
})
51+
}
52+
}
53+
54+
func TestExtractImagePlaceholderPaths(t *testing.T) {
55+
t.Parallel()
56+
57+
tests := []struct {
58+
name string
59+
in []string
60+
want []string
61+
}{
62+
{
63+
name: "no placeholders",
64+
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
65+
want: nil,
66+
},
67+
{
68+
name: "single placeholder",
69+
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
70+
want: []string{"./pic.png"},
71+
},
72+
{
73+
name: "single quotes",
74+
in: []string{`<img src='@./a.png'/>`},
75+
want: []string{"./a.png"},
76+
},
77+
{
78+
name: "dedup across slides",
79+
in: []string{
80+
`<slide><data><img src="@./shared.png"/></data></slide>`,
81+
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
82+
},
83+
want: []string{"./shared.png", "./other.png"},
84+
},
85+
{
86+
name: "ignores non-img src",
87+
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
88+
want: []string{"./real.png"},
89+
},
90+
{
91+
name: "preserves order of first occurrence",
92+
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
93+
want: []string{"b.png", "a.png"},
94+
},
95+
}
96+
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
t.Parallel()
100+
got := extractImagePlaceholderPaths(tt.in)
101+
if !reflect.DeepEqual(got, tt.want) {
102+
t.Fatalf("got %v, want %v", got, tt.want)
103+
}
104+
})
105+
}
106+
}
107+
108+
func TestReplaceImagePlaceholders(t *testing.T) {
109+
t.Parallel()
110+
111+
tokens := map[string]string{
112+
"./pic.png": "tok_abc",
113+
"./b.png": "tok_b",
114+
}
115+
116+
tests := []struct {
117+
name string
118+
in string
119+
want string
120+
}{
121+
{
122+
name: "single replacement preserves siblings",
123+
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
124+
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
125+
},
126+
{
127+
name: "multiple replacements",
128+
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
129+
want: `<img src="tok_abc"/><img src="tok_b"/>`,
130+
},
131+
{
132+
name: "single quotes",
133+
in: `<img src='@./pic.png'/>`,
134+
want: `<img src='tok_abc'/>`,
135+
},
136+
{
137+
name: "leaves unknown placeholder untouched",
138+
in: `<img src="@./missing.png"/>`,
139+
want: `<img src="@./missing.png"/>`,
140+
},
141+
{
142+
name: "leaves http url alone",
143+
in: `<img src="https://x.com/a.png"/>`,
144+
want: `<img src="https://x.com/a.png"/>`,
145+
},
146+
{
147+
name: "leaves bare token alone",
148+
in: `<img src="existing_token"/>`,
149+
want: `<img src="existing_token"/>`,
150+
},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
t.Parallel()
156+
got := replaceImagePlaceholders(tt.in, tokens)
157+
if got != tt.want {
158+
t.Fatalf("got %q\nwant %q", got, tt.want)
159+
}
160+
})
161+
}
162+
}

shortcuts/slides/shortcuts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
99
func Shortcuts() []common.Shortcut {
1010
return []common.Shortcut{
1111
SlidesCreate,
12+
SlidesMediaUpload,
1213
}
1314
}

0 commit comments

Comments
 (0)