diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go index 62ace6582d..a7a5f5d7cd 100644 --- a/shortcuts/doc/doc_errors_test.go +++ b/shortcuts/doc/doc_errors_test.go @@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) { str: map[string]string{"doc": testDocxToken, "command": "str_replace"}, wantParam: "--pattern", }, + { + name: "XML str_replace rejects multiline pattern", + str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"}, + wantParam: "--pattern", + }, { name: "block_delete without block id", str: map[string]string{"doc": testDocxToken, "command": "block_delete"}, wantParam: "--block-id", }, + { + name: "block_delete rejects empty ID", + str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"}, + wantParam: "--block-id", + }, + { + name: "block_delete rejects duplicate ID", + str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"}, + wantParam: "--block-id", + }, { name: "block_insert_after without block id", str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"}, diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index f5002ebb13..97d5443a2b 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -17,6 +17,46 @@ import ( // ── V2 (OpenAPI) tests ── +func TestStripTopLevelXMLTitles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want string + }{ + { + name: "single title", + content: "Content title

body

", + want: "

body

", + }, + { + name: "multiple titles", + content: "First\n

body

\nSecond", + want: "

body

", + }, + { + name: "nested title is preserved", + content: "Nested

body

", + want: "Nested

body

", + }, + { + name: "malformed XML is preserved", + content: "Content title

A & B

", + want: "Content title

A & B

", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := stripTopLevelXMLTitles(tt.content); got != tt.want { + t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want) + } + }) + } +} + func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index e5c5e36724..1a594dc10a 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "encoding/xml" + "errors" + "io" "strings" "github.com/larksuite/cli/errs" @@ -16,7 +18,7 @@ import ( // v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path. func v2CreateFlags() []common.Flag { return []common.Flag{ - {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"}, + {Name: "title", Desc: "document title; the CLI prepends it to --content as .... In XML mode, top-level elements in --content are removed so this flag wins without duplicate-title warnings"}, {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, @@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) if title == "" { return content } + if runtime.Str("doc-format") == "xml" { + content = stripTopLevelXMLTitles(content) + } titleTag := "<title>" + escapeDocTitleText(title) + "" if content == "" { @@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) return titleTag + "\n" + content } +type docContentRange struct { + start int64 + end int64 +} + +// stripTopLevelXMLTitles preserves the established --title-wins contract while +// avoiding duplicate-title warnings from XML content. If the fragment is not +// well-formed XML, it is left untouched for the service to diagnose. +func stripTopLevelXMLTitles(content string) string { + const wrapperStart = "" + wrapped := wrapperStart + content + "" + decoder := xml.NewDecoder(strings.NewReader(wrapped)) + wrapperLen := int64(len(wrapperStart)) + depth := 0 + activeStart := int64(-1) + ranges := make([]docContentRange, 0, 1) + + for { + tokenStart := decoder.InputOffset() + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return content + } + + switch value := token.(type) { + case xml.StartElement: + if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" { + activeStart = tokenStart - wrapperLen + } + depth++ + case xml.EndElement: + depth-- + if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" { + ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen}) + activeStart = -1 + } + } + } + + if len(ranges) == 0 { + return content + } + + var result strings.Builder + cursor := int64(0) + for _, item := range ranges { + result.WriteString(content[int(cursor):int(item.start)]) + cursor = item.end + } + result.WriteString(content[int(cursor):]) + return strings.TrimSpace(result.String()) +} + func escapeDocTitleText(title string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(title)) diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 755b35c70f..987dca2bf9 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag { {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, - {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, - {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"}, + {Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"}, + {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, {Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"}, } @@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { if pattern == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern") } + if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern") + } case "block_delete": if blockID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id") } + if err := validateBlockDeleteIDs(blockID); err != nil { + return err + } case "block_insert_after": if blockID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id") @@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return nil } +func validateBlockDeleteIDs(raw string) error { + seen := make(map[string]struct{}) + for _, part := range strings.Split(raw, ",") { + blockID := strings.TrimSpace(part) + if blockID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id") + } + if _, ok := seen[blockID]; ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id") + } + seen[blockID] = struct{}{} + } + return nil +} + +func normalizeBlockDeleteIDs(raw string) string { + parts := strings.Split(raw, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return strings.Join(parts, ",") +} + func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) @@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} body["pattern"] = v } if blockID != "" { + if cmd == "block_delete" { + blockID = normalizeBlockDeleteIDs(blockID) + } body["block_id"] = blockID } if v := runtime.Str("src-block-ids"); v != "" { diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 5219fa9f5a..33ba745499 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", - "--block-id", "blkA,blkB,blkC", + "--block-id", "blkA, blkB, blkC", "--dry-run", }, wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"}, + wantBody: map[string]any{"block_id": "blkA,blkB,blkC"}, }, { name: "history list", @@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) { require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out) require.Equal(t, "Dry Run & Title\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out) } + +func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", "Flag title", + "--content", "Content title

body

", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, "Flag title\n

body

", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String()) +} + +func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "multiline XML str_replace", + args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"}, + want: "must be inline", + }, + { + name: "duplicate block delete ID", + args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"}, + want: "duplicate ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"}) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want) + }) + } +}