mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
This commit is contained in:
1
go.mod
1
go.mod
@@ -4,7 +4,6 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/beevik/etree v1.5.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -8,8 +8,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/beevik/etree v1.5.1 h1:TC3zyxYp+81wAmbsi8SWUpZCurbxa6S8RITYRSkNRwo=
|
||||
github.com/beevik/etree v1.5.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
|
||||
@@ -7,12 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -20,10 +16,9 @@ import (
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes reindented XML to a local file, and --raw prints
|
||||
// reindented XML to stdout; otherwise it returns the server's original
|
||||
// content unmodified in the standard JSON envelope. Use --slide-id or
|
||||
// --slide-number to fetch one page.
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
|
||||
// and use --raw for direct XML stdout.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
@@ -35,8 +30,8 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
@@ -113,10 +108,10 @@ var SlidesXMLGet = common.Shortcut{
|
||||
}
|
||||
dry.GET(path).Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
|
||||
},
|
||||
@@ -255,31 +250,22 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
|
||||
// Only the text surfaces are reindented: --raw stdout and --output files are
|
||||
// read directly by humans and line tools. The JSON envelope carries the
|
||||
// server content verbatim instead -- inside a JSON string every newline is
|
||||
// escaped to \n, so formatting there buys no readability and only inflates
|
||||
// the payload, while passthrough keeps that read path byte-exact without
|
||||
// even parsing the content.
|
||||
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
|
||||
if outputPath == "" {
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(formatted)),
|
||||
}, bytes.NewReader([]byte(formatted)))
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
@@ -294,7 +280,6 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
"pretty_printed": prettyPrinted,
|
||||
}
|
||||
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
|
||||
if value, ok := out[key]; ok {
|
||||
@@ -304,192 +289,3 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
runtime.Out(fileOut, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// textBearingTags are the SML elements whose schema content model is
|
||||
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
|
||||
// container and its inline formatting children, plus chart title/subtitle.
|
||||
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
|
||||
// or tab is represented via  /	 character references. Those references
|
||||
// are masked before XML parsing so etree cannot resolve away their lexical
|
||||
// representation, and reindentStructural never descends into these elements.
|
||||
var textBearingTags = map[string]bool{
|
||||
"p": true,
|
||||
"strong": true,
|
||||
"em": true,
|
||||
"u": true,
|
||||
"span": true,
|
||||
"del": true,
|
||||
"a": true,
|
||||
"shadow": true,
|
||||
"outline": true,
|
||||
"chartTitle": true,
|
||||
"chartSubTitle": true,
|
||||
}
|
||||
|
||||
// prettyPrintXML reindents xmlContent so structural elements (presentation,
|
||||
// slide, shape, style, ...) each sit on their own line. The server returns
|
||||
// XML as a single unbroken line, and this is what makes the --raw and
|
||||
// --output text surfaces readable; the JSON envelope path never calls it
|
||||
// (see outputSlidesXMLGetContent).
|
||||
//
|
||||
// Reindentation never enters a textBearingTags element. XML whitespace
|
||||
// character references are masked before parsing and restored after
|
||||
// serialization, and CDATA sections are preserved rather than collapsed into
|
||||
// escaped text. This covers the schema-documented space/tab references as well
|
||||
// as CR/LF references: serializing a CR reference as a literal line ending
|
||||
// would make a later XML parse normalize it to LF and break read-modify-write
|
||||
// round trips.
|
||||
func prettyPrintXML(xmlContent string) (string, error) {
|
||||
maskedContent, maskedReferences := maskSMLWhitespaceCharacterReferences(xmlContent)
|
||||
doc := etree.NewDocument()
|
||||
doc.ReadSettings.PreserveCData = true
|
||||
if err := doc.ReadFromString(maskedContent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if root := doc.Root(); root != nil {
|
||||
reindentStructural(root, 0)
|
||||
}
|
||||
out, err := doc.WriteToString()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out = restoreSMLWhitespaceCharacterReferences(out, maskedReferences)
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
|
||||
// content that is not strictly valid XML, callers still receive the original
|
||||
// content and a warning on stderr instead of losing the read path. The bool
|
||||
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
|
||||
// --output file metadata.
|
||||
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
|
||||
out, err := prettyPrintXML(xmlContent)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
|
||||
return xmlContent, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
type maskedXMLCharacterReference struct {
|
||||
placeholder string
|
||||
reference string
|
||||
}
|
||||
|
||||
var numericXMLCharacterReferencePattern = regexp.MustCompile(`&#(?:[0-9]+|x[0-9A-Fa-f]+);`)
|
||||
|
||||
// maskSMLWhitespaceCharacterReferences protects XML whitespace character
|
||||
// references (space, tab, CR, and LF) from etree's parse/write normalization.
|
||||
// Each original spelling is restored exactly, including decimal, hexadecimal,
|
||||
// and zero-padded forms. The placeholder prefix is chosen not to occur in the
|
||||
// input, so restoring cannot rewrite user-authored content accidentally.
|
||||
func maskSMLWhitespaceCharacterReferences(xmlContent string) (string, []maskedXMLCharacterReference) {
|
||||
placeholderPrefix := "LARKCLI_XML_WHITESPACE_REFERENCE_"
|
||||
for strings.Contains(xmlContent, placeholderPrefix) {
|
||||
placeholderPrefix += "_"
|
||||
}
|
||||
|
||||
maskedReferences := make([]maskedXMLCharacterReference, 0)
|
||||
maskedContent := numericXMLCharacterReferencePattern.ReplaceAllStringFunc(xmlContent, func(reference string) string {
|
||||
digits := reference[2 : len(reference)-1]
|
||||
base := 10
|
||||
if strings.HasPrefix(digits, "x") {
|
||||
base = 16
|
||||
digits = digits[1:]
|
||||
}
|
||||
value, err := strconv.ParseUint(digits, base, 32)
|
||||
if err != nil {
|
||||
return reference
|
||||
}
|
||||
switch value {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
default:
|
||||
return reference
|
||||
}
|
||||
|
||||
placeholder := fmt.Sprintf("%s%d_", placeholderPrefix, len(maskedReferences))
|
||||
maskedReferences = append(maskedReferences, maskedXMLCharacterReference{
|
||||
placeholder: placeholder,
|
||||
reference: reference,
|
||||
})
|
||||
return placeholder
|
||||
})
|
||||
return maskedContent, maskedReferences
|
||||
}
|
||||
|
||||
func restoreSMLWhitespaceCharacterReferences(xmlContent string, maskedReferences []maskedXMLCharacterReference) string {
|
||||
for _, masked := range maskedReferences {
|
||||
xmlContent = strings.ReplaceAll(xmlContent, masked.placeholder, masked.reference)
|
||||
}
|
||||
return xmlContent
|
||||
}
|
||||
|
||||
// reindentStructural inserts newline+indent whitespace between an element's
|
||||
// direct children so each nested element sits on its own line, recursing
|
||||
// only into children that are not textBearingTags. Existing whitespace-only
|
||||
// CharData between children is treated as pre-existing formatting and
|
||||
// dropped before reinserting it at the correct depth; non-whitespace CharData
|
||||
// is never touched, and text-bearing subtrees are never entered at all.
|
||||
func reindentStructural(e *etree.Element, depth int) {
|
||||
// A node without element children has no nested structure to reindent.
|
||||
// Leaving the whole leaf untouched also preserves whitespace-only xs:string
|
||||
// and simple-content values such as <title> and <chartField>, including
|
||||
// adjacent plain-text and CDATA nodes.
|
||||
if textBearingTags[e.Tag] || !hasElementChild(e) {
|
||||
return
|
||||
}
|
||||
|
||||
for i := len(e.Child) - 1; i >= 0; i-- {
|
||||
if cd, ok := e.Child[i].(*etree.CharData); ok && isAllWhitespace(cd.Data) {
|
||||
e.RemoveChildAt(i)
|
||||
}
|
||||
}
|
||||
if len(e.Child) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, lastIsCharData := e.Child[len(e.Child)-1].(*etree.CharData)
|
||||
childIndent := "\n" + strings.Repeat(" ", depth+1)
|
||||
closeIndent := "\n" + strings.Repeat(" ", depth)
|
||||
|
||||
for i := len(e.Child) - 1; i >= 0; i-- {
|
||||
child := e.Child[i]
|
||||
if ce, ok := child.(*etree.Element); ok {
|
||||
reindentStructural(ce, depth+1)
|
||||
}
|
||||
if _, isCharData := child.(*etree.CharData); !isCharData {
|
||||
e.InsertChildAt(i, etree.NewCharData(childIndent))
|
||||
}
|
||||
}
|
||||
if !lastIsCharData {
|
||||
e.AddChild(etree.NewCharData(closeIndent))
|
||||
}
|
||||
}
|
||||
|
||||
func hasElementChild(e *etree.Element) bool {
|
||||
for _, child := range e.Child {
|
||||
if _, ok := child.(*etree.Element); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllWhitespace reports whether s is non-empty and consists only of XML
|
||||
// whitespace characters (space, tab, CR, LF).
|
||||
func isAllWhitespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML (not derived by
|
||||
// calling it): a bug in prettyPrintXML itself must not be able to make
|
||||
// this assertion pass by construction.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -64,10 +60,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), wantXML) {
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
@@ -84,11 +80,8 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["pretty_printed"] != true {
|
||||
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
|
||||
}
|
||||
if data["size"] != float64(len(wantXML)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
@@ -103,12 +96,7 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The JSON envelope carries the server content verbatim: no reindentation
|
||||
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
|
||||
// would fail this by inserting indentation; the   reference
|
||||
// additionally guards against a plain unmasked XML round trip, which
|
||||
// would decode it to a literal space.
|
||||
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span> <strong>World</strong></p></content></shape></slide></presentation>`
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -134,14 +122,11 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
@@ -151,8 +136,6 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// --jq extracts fields from the envelope, and the envelope carries the
|
||||
// server content verbatim, so the filter yields the single-line original.
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -178,18 +161,15 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -213,32 +193,16 @@ func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != wantXML {
|
||||
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
|
||||
for _, flag := range SlidesXMLGet.Flags {
|
||||
if flag.Name != "raw" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
|
||||
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("--raw flag not found")
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -280,8 +244,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved slide XML: %v", err)
|
||||
}
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
@@ -299,8 +263,6 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The slide envelope carries the server content verbatim, like the
|
||||
// presentation envelope.
|
||||
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -343,14 +305,11 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
}
|
||||
slide := data["slide"].(map[string]interface{})
|
||||
if slide["content"] != xml {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
|
||||
t.Fatalf("content = %q, want %q", slide["content"], xml)
|
||||
}
|
||||
if slide["slide_id"] != "slide_2" {
|
||||
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
@@ -556,341 +515,3 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
|
||||
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXML(t *testing.T) {
|
||||
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
|
||||
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Fatalf("expected reindented output with newlines, got %q", got)
|
||||
}
|
||||
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
|
||||
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
|
||||
}
|
||||
if !strings.Contains(got, "<data/>") {
|
||||
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
|
||||
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
|
||||
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
|
||||
t.Fatal("expected an error for malformed XML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
|
||||
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
|
||||
// element docs) and CR/LF references whose lexical form is needed to avoid
|
||||
// XML line-ending normalization on a later parse. etree normally decodes the
|
||||
// references into literal whitespace. The formatter must preserve their
|
||||
// lexical representation for safe read-modify-write workflows.
|
||||
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"space in p", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"tab in p", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"space in nested span", `<content><p><span> </span></p></content>`, "<content>\n <p><span> </span></p>\n</content>\n"},
|
||||
{"hex space", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"zero-padded tab", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"carriage return", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"line feed", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"hex carriage return", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
{"hex line feed", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "title literal space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title escaped space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title whitespace CDATA",
|
||||
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "chart field literal space",
|
||||
input: `<chartData><chartField name="x"> </chartField></chartData>`,
|
||||
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
|
||||
},
|
||||
{
|
||||
name: "title adjacent text and CDATA",
|
||||
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
|
||||
// critical case:   sitting as a bare sibling text node directly between
|
||||
// two inline elements, not wrapped in its own tag -- the literal reading of
|
||||
// the schema's "标签之间...请使用 " guidance, e.g. a plain-styled space
|
||||
// between two differently formatted words at a pptx run boundary. A fix
|
||||
// that only special-cases "element whose sole content is whitespace" (such
|
||||
// as etree's PreserveLeafWhitespace) does not cover this: the whitespace
|
||||
// here is one of several children of <p>, not the sole child of <span>.
|
||||
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
|
||||
input := `<content><p><span>Hello</span> <strong>World</strong></p></content>`
|
||||
want := "<content>\n <p><span>Hello</span> <strong>World</strong></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
|
||||
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
|
||||
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
|
||||
// feature's actual point: a shape with many paragraphs becomes navigable
|
||||
// (each <p> on its own indented line), while every paragraph's own rich
|
||||
// text -- including an inline formatting boundary -- stays byte-for-byte
|
||||
// unchanged.
|
||||
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
|
||||
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
|
||||
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLIdempotent(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a"><content><p>A  B	C D E</p></content><style/></shape></slide></presentation>`
|
||||
once, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (first pass): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(once)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (second pass): %v", err)
|
||||
}
|
||||
if once != twice {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
|
||||
content := "<presentation><title>\x0b</title><slide/></presentation>"
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != content {
|
||||
t.Fatalf("stdout = %q, want original content %q", got, content)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
|
||||
// envelope contract: the content is never parsed, so even malformed XML
|
||||
// flows through byte for byte with no fallback warning and no
|
||||
// pretty_printed field.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
|
||||
content := `<slide><data></slide>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_1",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
slide, _ := data["slide"].(map[string]interface{})
|
||||
if slide == nil {
|
||||
t.Fatalf("missing slide: %#v", data)
|
||||
}
|
||||
if got, _ := slide["content"].(string); got != content {
|
||||
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
|
||||
// the slide-scope passthrough test for the presentation-scope fetch branch,
|
||||
// which is a separate code path.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation, _ := data["xml_presentation"].(map[string]interface{})
|
||||
if presentation == nil {
|
||||
t.Fatalf("missing xml_presentation: %#v", data)
|
||||
}
|
||||
if got, _ := presentation["content"].(string); got != content {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "fallback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback XML: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("saved XML = %q, want original content %q", got, content)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["pretty_printed"] != false {
|
||||
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user