Skip to content

Commit 0b8e956

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 0b8e956

14 files changed

Lines changed: 1438 additions & 7 deletions

shortcuts/slides/helpers.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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+
// imgPlaceholderPrefix is the magic char prefix we use inside <img src="..."> to
98+
// indicate "this is a local file path; upload it and replace with file_token".
99+
const imgPlaceholderPrefix = "@"
100+
101+
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
102+
//
103+
// Match groups:
104+
//
105+
// 1: opening quote character (so we can replace symmetrically)
106+
// 2: the path string (everything inside the quotes after the leading @)
107+
//
108+
// We deliberately scope to <img ... src="@..."> rather than any src= so other
109+
// schema elements (like icon/iconType) aren't accidentally rewritten.
110+
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc=(["'])@([^"']+)(["'])`)
111+
112+
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
113+
// referenced via <img src="@path"> in the given slide XML strings.
114+
//
115+
// Order is preserved (first occurrence wins) so dry-run / progress messages are
116+
// stable across runs.
117+
func extractImagePlaceholderPaths(slideXMLs []string) []string {
118+
var paths []string
119+
seen := map[string]bool{}
120+
for _, xml := range slideXMLs {
121+
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
122+
for _, m := range matches {
123+
path := strings.TrimSpace(m[2])
124+
if path == "" || seen[path] {
125+
continue
126+
}
127+
seen[path] = true
128+
paths = append(paths, path)
129+
}
130+
}
131+
return paths
132+
}
133+
134+
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
135+
// XML by looking up each path in tokens. Paths missing from the map are left
136+
// untouched (callers should ensure the map is complete).
137+
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
138+
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
139+
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
140+
if len(sub) < 4 {
141+
return match
142+
}
143+
quote, path, closeQuote := sub[1], sub[2], sub[3]
144+
token, ok := tokens[strings.TrimSpace(path)]
145+
if !ok {
146+
return match
147+
}
148+
// Preserve everything before src= by replacing only the src=... segment.
149+
// Using strings.Replace on the matched substring keeps surrounding attrs intact.
150+
oldSrc := fmt.Sprintf("src=%s@%s%s", quote, path, closeQuote)
151+
newSrc := fmt.Sprintf("src=%s%s%s", quote, token, closeQuote)
152+
return strings.Replace(match, oldSrc, newSrc, 1)
153+
})
154+
}

shortcuts/slides/helpers_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
tt := tt
36+
t.Run(tt.name, func(t *testing.T) {
37+
t.Parallel()
38+
got, err := parsePresentationRef(tt.input)
39+
if tt.wantErr != "" {
40+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
41+
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
42+
}
43+
return
44+
}
45+
if err != nil {
46+
t.Fatalf("unexpected error: %v", err)
47+
}
48+
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
49+
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
50+
}
51+
})
52+
}
53+
}
54+
55+
func TestExtractImagePlaceholderPaths(t *testing.T) {
56+
t.Parallel()
57+
58+
tests := []struct {
59+
name string
60+
in []string
61+
want []string
62+
}{
63+
{
64+
name: "no placeholders",
65+
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
66+
want: nil,
67+
},
68+
{
69+
name: "single placeholder",
70+
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
71+
want: []string{"./pic.png"},
72+
},
73+
{
74+
name: "single quotes",
75+
in: []string{`<img src='@./a.png'/>`},
76+
want: []string{"./a.png"},
77+
},
78+
{
79+
name: "dedup across slides",
80+
in: []string{
81+
`<slide><data><img src="@./shared.png"/></data></slide>`,
82+
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
83+
},
84+
want: []string{"./shared.png", "./other.png"},
85+
},
86+
{
87+
name: "ignores non-img src",
88+
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
89+
want: []string{"./real.png"},
90+
},
91+
{
92+
name: "preserves order of first occurrence",
93+
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
94+
want: []string{"b.png", "a.png"},
95+
},
96+
}
97+
98+
for _, tt := range tests {
99+
tt := tt
100+
t.Run(tt.name, func(t *testing.T) {
101+
t.Parallel()
102+
got := extractImagePlaceholderPaths(tt.in)
103+
if !reflect.DeepEqual(got, tt.want) {
104+
t.Fatalf("got %v, want %v", got, tt.want)
105+
}
106+
})
107+
}
108+
}
109+
110+
func TestReplaceImagePlaceholders(t *testing.T) {
111+
t.Parallel()
112+
113+
tokens := map[string]string{
114+
"./pic.png": "tok_abc",
115+
"./b.png": "tok_b",
116+
}
117+
118+
tests := []struct {
119+
name string
120+
in string
121+
want string
122+
}{
123+
{
124+
name: "single replacement preserves siblings",
125+
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
126+
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
127+
},
128+
{
129+
name: "multiple replacements",
130+
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
131+
want: `<img src="tok_abc"/><img src="tok_b"/>`,
132+
},
133+
{
134+
name: "single quotes",
135+
in: `<img src='@./pic.png'/>`,
136+
want: `<img src='tok_abc'/>`,
137+
},
138+
{
139+
name: "leaves unknown placeholder untouched",
140+
in: `<img src="@./missing.png"/>`,
141+
want: `<img src="@./missing.png"/>`,
142+
},
143+
{
144+
name: "leaves http url alone",
145+
in: `<img src="https://x.com/a.png"/>`,
146+
want: `<img src="https://x.com/a.png"/>`,
147+
},
148+
{
149+
name: "leaves bare token alone",
150+
in: `<img src="existing_token"/>`,
151+
want: `<img src="existing_token"/>`,
152+
},
153+
}
154+
155+
for _, tt := range tests {
156+
tt := tt
157+
t.Run(tt.name, func(t *testing.T) {
158+
t.Parallel()
159+
got := replaceImagePlaceholders(tt.in, tokens)
160+
if got != tt.want {
161+
t.Fatalf("got %q\nwant %q", got, tt.want)
162+
}
163+
})
164+
}
165+
}

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)