|
| 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 | +} |
0 commit comments