Skip to content

Commit 3198b6c

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 3198b6c

14 files changed

Lines changed: 1488 additions & 8 deletions

shortcuts/slides/helpers.go

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

shortcuts/slides/helpers_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
// Regression: /slides/ inside a query string must NOT be treated as a slides marker.
33+
{name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"},
34+
// Regression: /wiki/ as a path segment but not a prefix must not match.
35+
{name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"},
36+
// Regression: bare relative path containing wiki/ is not a wiki ref.
37+
{name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"},
38+
}
39+
40+
for _, tt := range tests {
41+
t.Run(tt.name, func(t *testing.T) {
42+
t.Parallel()
43+
got, err := parsePresentationRef(tt.input)
44+
if tt.wantErr != "" {
45+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
46+
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
47+
}
48+
return
49+
}
50+
if err != nil {
51+
t.Fatalf("unexpected error: %v", err)
52+
}
53+
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
54+
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
55+
}
56+
})
57+
}
58+
}
59+
60+
func TestExtractImagePlaceholderPaths(t *testing.T) {
61+
t.Parallel()
62+
63+
tests := []struct {
64+
name string
65+
in []string
66+
want []string
67+
}{
68+
{
69+
name: "no placeholders",
70+
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
71+
want: nil,
72+
},
73+
{
74+
name: "single placeholder",
75+
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
76+
want: []string{"./pic.png"},
77+
},
78+
{
79+
name: "single quotes",
80+
in: []string{`<img src='@./a.png'/>`},
81+
want: []string{"./a.png"},
82+
},
83+
{
84+
name: "dedup across slides",
85+
in: []string{
86+
`<slide><data><img src="@./shared.png"/></data></slide>`,
87+
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
88+
},
89+
want: []string{"./shared.png", "./other.png"},
90+
},
91+
{
92+
name: "ignores non-img src",
93+
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
94+
want: []string{"./real.png"},
95+
},
96+
{
97+
name: "preserves order of first occurrence",
98+
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
99+
want: []string{"b.png", "a.png"},
100+
},
101+
{
102+
// Regression: Go RE2 has no backreferences, so the regex captures
103+
// opening and closing quotes independently. Mismatched pairs must
104+
// be filtered out post-match instead of producing bogus paths.
105+
name: "rejects mismatched quotes",
106+
in: []string{`<img src="@./oops.png'/>`},
107+
want: nil,
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
t.Parallel()
114+
got := extractImagePlaceholderPaths(tt.in)
115+
if !reflect.DeepEqual(got, tt.want) {
116+
t.Fatalf("got %v, want %v", got, tt.want)
117+
}
118+
})
119+
}
120+
}
121+
122+
func TestReplaceImagePlaceholders(t *testing.T) {
123+
t.Parallel()
124+
125+
tokens := map[string]string{
126+
"./pic.png": "tok_abc",
127+
"./b.png": "tok_b",
128+
}
129+
130+
tests := []struct {
131+
name string
132+
in string
133+
want string
134+
}{
135+
{
136+
name: "single replacement preserves siblings",
137+
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
138+
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
139+
},
140+
{
141+
name: "multiple replacements",
142+
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
143+
want: `<img src="tok_abc"/><img src="tok_b"/>`,
144+
},
145+
{
146+
name: "single quotes",
147+
in: `<img src='@./pic.png'/>`,
148+
want: `<img src='tok_abc'/>`,
149+
},
150+
{
151+
name: "leaves unknown placeholder untouched",
152+
in: `<img src="@./missing.png"/>`,
153+
want: `<img src="@./missing.png"/>`,
154+
},
155+
{
156+
name: "leaves http url alone",
157+
in: `<img src="https://x.com/a.png"/>`,
158+
want: `<img src="https://x.com/a.png"/>`,
159+
},
160+
{
161+
name: "leaves bare token alone",
162+
in: `<img src="existing_token"/>`,
163+
want: `<img src="existing_token"/>`,
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
t.Parallel()
170+
got := replaceImagePlaceholders(tt.in, tokens)
171+
if got != tt.want {
172+
t.Fatalf("got %q\nwant %q", got, tt.want)
173+
}
174+
})
175+
}
176+
}

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)