Skip to content

Commit ec9e67c

Browse files
authored
feat(slides): add image upload via +media-upload and @path placeholders in +create (#450)
- New `slides +media-upload` shortcut: upload a local image to a slides presentation and return the file_token for use in <img src="...">. - `slides +create --slides` now supports `@./path.png` placeholders that are auto-uploaded and replaced with file_tokens. - Reject images >20 MB (multipart upload not supported for slide_file). - Support wiki URL resolution for --presentation flag.
1 parent 74e4a97 commit ec9e67c

14 files changed

Lines changed: 1487 additions & 8 deletions

shortcuts/slides/helpers.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
121+
// around `=`); without it we'd silently leave such placeholders unrewritten.
122+
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
123+
124+
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
125+
// referenced via <img src="@path"> in the given slide XML strings.
126+
//
127+
// Order is preserved (first occurrence wins) so dry-run / progress messages are
128+
// stable across runs.
129+
func extractImagePlaceholderPaths(slideXMLs []string) []string {
130+
var paths []string
131+
seen := map[string]bool{}
132+
for _, xml := range slideXMLs {
133+
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
134+
for _, m := range matches {
135+
if m[1] != m[3] {
136+
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
137+
// so we filter it here. Treat as malformed XML and skip.
138+
continue
139+
}
140+
path := strings.TrimSpace(m[2])
141+
if path == "" || seen[path] {
142+
continue
143+
}
144+
seen[path] = true
145+
paths = append(paths, path)
146+
}
147+
}
148+
return paths
149+
}
150+
151+
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
152+
// XML by looking up each path in tokens. Paths missing from the map are left
153+
// untouched (callers should ensure the map is complete).
154+
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
155+
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
156+
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
157+
if len(sub) < 4 {
158+
return match
159+
}
160+
quote, path, closeQuote := sub[1], sub[2], sub[3]
161+
if quote != closeQuote {
162+
// Mismatched quotes — see extractImagePlaceholderPaths.
163+
return match
164+
}
165+
token, ok := tokens[strings.TrimSpace(path)]
166+
if !ok {
167+
return match
168+
}
169+
// Replace only the `"@<path>"` segment (quotes inclusive) so any
170+
// surrounding attrs and whitespace around `=` stay intact. Looking up
171+
// by the literal `@<path>"` (with closing quote) avoids accidentally
172+
// matching the same path elsewhere in the tag.
173+
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
174+
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
175+
return strings.Replace(match, oldQuoted, newQuoted, 1)
176+
})
177+
}

shortcuts/slides/helpers_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
// Regression: XML allows whitespace around `=`; placeholders in
111+
// `src = "@..."` form must still be detected.
112+
name: "tolerates whitespace around equals",
113+
in: []string{`<img src = "@./spaced.png" />`},
114+
want: []string{"./spaced.png"},
115+
},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
t.Parallel()
121+
got := extractImagePlaceholderPaths(tt.in)
122+
if !reflect.DeepEqual(got, tt.want) {
123+
t.Fatalf("got %v, want %v", got, tt.want)
124+
}
125+
})
126+
}
127+
}
128+
129+
func TestReplaceImagePlaceholders(t *testing.T) {
130+
t.Parallel()
131+
132+
tokens := map[string]string{
133+
"./pic.png": "tok_abc",
134+
"./b.png": "tok_b",
135+
}
136+
137+
tests := []struct {
138+
name string
139+
in string
140+
want string
141+
}{
142+
{
143+
name: "single replacement preserves siblings",
144+
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
145+
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
146+
},
147+
{
148+
name: "multiple replacements",
149+
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
150+
want: `<img src="tok_abc"/><img src="tok_b"/>`,
151+
},
152+
{
153+
name: "single quotes",
154+
in: `<img src='@./pic.png'/>`,
155+
want: `<img src='tok_abc'/>`,
156+
},
157+
{
158+
name: "leaves unknown placeholder untouched",
159+
in: `<img src="@./missing.png"/>`,
160+
want: `<img src="@./missing.png"/>`,
161+
},
162+
{
163+
name: "leaves http url alone",
164+
in: `<img src="https://x.com/a.png"/>`,
165+
want: `<img src="https://x.com/a.png"/>`,
166+
},
167+
{
168+
name: "leaves bare token alone",
169+
in: `<img src="existing_token"/>`,
170+
want: `<img src="existing_token"/>`,
171+
},
172+
{
173+
// Regression: placeholders with whitespace around `=` must be
174+
// rewritten too (XML permits the form). Surrounding whitespace
175+
// is preserved so the rewritten attribute reads naturally.
176+
name: "tolerates whitespace around equals",
177+
in: `<img src = "@./pic.png" topLeftX="10"/>`,
178+
want: `<img src = "tok_abc" topLeftX="10"/>`,
179+
},
180+
}
181+
182+
for _, tt := range tests {
183+
t.Run(tt.name, func(t *testing.T) {
184+
t.Parallel()
185+
got := replaceImagePlaceholders(tt.in, tokens)
186+
if got != tt.want {
187+
t.Fatalf("got %q\nwant %q", got, tt.want)
188+
}
189+
})
190+
}
191+
}

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)