Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

310 lines
13 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// presentationRef holds a parsed --presentation input.
//
// Slides shortcuts accept three input shapes:
// - a raw xml_presentation_id token
// - a slides URL like https://<host>/slides/<token>
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
type presentationRef struct {
Kind string // "slides" | "wiki"
Token string
}
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
// obtain the real xml_presentation_id and verify obj_type=slides.
func parsePresentationRef(input string) (presentationRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
}
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
// appear as a prefix of the URL path. Substring matching previously let
// e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc".
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
return presentationRef{Kind: "slides", Token: token}, nil
}
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
return presentationRef{Kind: "wiki", Token: token}, nil
}
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
// Non-URL input must be a bare token — anything with path/query/fragment
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
// get silently accepted.
if strings.ContainsAny(raw, "/?#") {
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
return presentationRef{Kind: "slides", Token: raw}, nil
}
// tokenAfterPathPrefix extracts the first path segment after prefix from path.
// Returns ("", false) if path doesn't start with prefix or the segment is empty.
func tokenAfterPathPrefix(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
// must resolve to obj_type=slides.
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
switch ref.Kind {
case "slides":
return ref.Token, nil
case "wiki":
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
}
return objToken, nil
default:
// Unreachable: ref.Kind is set only by parsePresentationRef, which
// emits exclusively "slides" or "wiki". A hit here means an internal
// invariant broke (e.g. a new kind added without updating this switch),
// not bad user input — classify as internal, not validation.
return "", errs.NewInternalError(errs.SubtypeUnknown, "unsupported presentation ref kind %q", ref.Kind)
}
}
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
// The "@" prefix is the magic marker for "this is a local file path; upload it and
// replace with file_token".
//
// Match groups:
//
// 1: opening quote character (so we can replace symmetrically)
// 2: the path string (everything inside the quotes after the leading @)
//
// We deliberately scope to <img ... src="@..."> rather than any src= so other
// schema elements (like icon/iconType) aren't accidentally rewritten.
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
// around `=`); without it we'd silently leave such placeholders unrewritten.
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
// referenced via <img src="@path"> in the given slide XML strings.
//
// Order is preserved (first occurrence wins) so dry-run / progress messages are
// stable across runs.
func extractImagePlaceholderPaths(slideXMLs []string) []string {
var paths []string
seen := map[string]bool{}
for _, xml := range slideXMLs {
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
for _, m := range matches {
if m[1] != m[3] {
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
// so we filter it here. Treat as malformed XML and skip.
continue
}
path := strings.TrimSpace(m[2])
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
// xmlRootOpenTagRegex matches the first opening tag of an XML fragment:
// skipping leading whitespace, XML declaration (<?...?>), and comments
// (<!-- ... -->).
//
// Match groups:
//
// 1: leading prefix (whitespace / decl / comments) — preserved on rewrite
// 2: tag name
// 3: attributes span (may be empty; leading whitespace included)
// 4: closing marker — "/>" (self-closing) or ">" (open tag)
//
// Regex is (?s) so "." crosses newlines; we anchor with \A so the opener
// really is the fragment's root, not any nested <el> later in the string.
var xmlRootOpenTagRegex = regexp.MustCompile(`(?s)\A(\s*(?:<\?[^?]*(?:\?[^>][^?]*)*\?>\s*)?(?:<!--.*?-->\s*)*)<([A-Za-z_][\w.-]*)((?:\s[^>]*?)?)(/?>)`)
// xmlIdAttrRegex matches a standalone `id="..."` or `id='...'` attribute
// (with optional whitespace around `=`). Group 1 is the quote char, group 2
// the value. Case-sensitive: XML attribute names are case-sensitive and the
// SML 2.0 schema uses lowercase `id`.
//
// Uses (?:^|\s) instead of \b so that attributes whose names merely contain
// "id" as a suffix (e.g. data-id, xml:id) are not accidentally matched —
// \b treats the '-' / ':' before "id" as a word boundary and would fire.
var xmlIdAttrRegex = regexp.MustCompile(`(?s)(?:^|\s)id\s*=\s*(["'])(.*?)(["'])`)
// ensureXMLRootID parses xmlFragment as XML, locates the root element's
// opening tag, and ensures it carries id="want". Behavior:
//
// - root has no id → inject ` id="want"` into the attributes span
// - root has id and value == want → returned unchanged
// - root has id but value != want → value overridden with want
//
// Whitespace, surrounding attributes, and self-closing form are preserved.
// Nested elements are never touched. Returns an error when no root element
// can be found (empty/malformed fragment).
//
// The regex approach matches the pattern used by imgSrcPlaceholderRegex
// elsewhere in this package: preserve caller formatting instead of round-
// tripping through encoding/xml (which reformats whitespace and loses
// attribute order).
func ensureXMLRootID(xmlFragment, want string) (string, error) {
m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment)
if m == nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
}
prefix := xmlFragment[m[2]:m[3]]
tagName := xmlFragment[m[4]:m[5]]
attrs := xmlFragment[m[6]:m[7]]
closer := xmlFragment[m[8]:m[9]]
rest := xmlFragment[m[1]:]
// Check for existing id in the attrs span.
if sub := xmlIdAttrRegex.FindStringSubmatchIndex(attrs); sub != nil {
if attrs[sub[4]:sub[5]] == want {
return xmlFragment, nil
}
// Override: replace only the value between the existing quotes;
// the original quote style is preserved because we only touch [sub[4]:sub[5]].
newAttrs := attrs[:sub[4]] + want + attrs[sub[5]:]
return prefix + "<" + tagName + newAttrs + closer + rest, nil
}
// No id → inject ` id="want"` at the end of the attrs span, preserving
// any pre-closer whitespace (e.g. the " " in `<shape type="rect" />`
// before `/>`). We split the span into (content, trailing-ws), append
// our attr to the content side, then put the trailing whitespace back.
trimmed := strings.TrimRight(attrs, " \t\n\r")
trailing := attrs[len(trimmed):]
injected := trimmed + fmt.Sprintf(` id="%s"`, want) + trailing
return prefix + "<" + tagName + injected + closer + rest, nil
}
// xmlContentTagRegex matches a <content> opening tag in its various valid
// forms (open tag, self-closing, or with attributes). The character after
// "content" must be whitespace, '/', or '>' — this ensures that tags whose
// names merely start with "content" (e.g. <contention/>) are not matched.
var xmlContentTagRegex = regexp.MustCompile(`<content(?:\s|/|>)`)
// ensureShapeHasContent ensures that a <shape> root element has a <content/>
// child. The SML 2.0 schema requires every <shape> to carry <content/>; a
// self-closing <shape .../> or an open <shape> without <content> causes the
// backend to return 3350001 (invalid param). Auto-injecting here mirrors the
// id-injection done by ensureXMLRootID — users write natural XML and the CLI
// patches in the required boilerplate.
//
// Only <shape> elements are affected; <img>, <table>, <chart> etc. are left
// untouched because they have different child-element schemas.
func ensureShapeHasContent(xmlFragment string) string {
m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment)
if m == nil {
return xmlFragment
}
tagName := xmlFragment[m[4]:m[5]]
if tagName != "shape" {
return xmlFragment
}
closer := xmlFragment[m[8]:m[9]]
if closer == "/>" {
prefix := xmlFragment[m[2]:m[3]]
attrs := xmlFragment[m[6]:m[7]]
trimmed := strings.TrimRight(attrs, " \t\n\r")
rest := xmlFragment[m[1]:]
return prefix + "<" + tagName + trimmed + "><content/></" + tagName + ">" + rest
}
afterOpen := xmlFragment[m[1]:]
if xmlContentTagRegex.MatchString(afterOpen) {
return xmlFragment
}
closeTag := "</" + tagName + ">"
closeIdx := strings.Index(afterOpen, closeTag)
if closeIdx < 0 {
return xmlFragment
}
// Only inject when the shape body is empty. If the user already wrote
// non-content children (e.g. `<shape type="text"><p>hi</p></shape>`),
// prepending `<content/>` would make <p> a sibling of <content> — per
// SML 2.0 <p> must live inside <content>, so the result would be schema-
// legal but semantically wrong (empty content + stray <p>). Leave that
// case to the backend's 3350001 rather than silently rewrap children.
if strings.TrimSpace(afterOpen[:closeIdx]) != "" {
return xmlFragment
}
return xmlFragment[:m[1]] + "<content/>" + afterOpen
}
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
// XML by looking up each path in tokens. Paths missing from the map are left
// untouched (callers should ensure the map is complete).
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
if len(sub) < 4 {
return match
}
quote, path, closeQuote := sub[1], sub[2], sub[3]
if quote != closeQuote {
// Mismatched quotes — see extractImagePlaceholderPaths.
return match
}
token, ok := tokens[strings.TrimSpace(path)]
if !ok {
return match
}
// Replace only the `"@<path>"` segment (quotes inclusive) so any
// surrounding attrs and whitespace around `=` stay intact. Looking up
// by the literal `@<path>"` (with closing quote) avoids accidentally
// matching the same path elsewhere in the tag.
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
return strings.Replace(match, oldQuoted, newQuoted, 1)
})
}