Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions shortcuts/doc/docs_fetch_im_markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ import (
"html"
"net/url"
"regexp"
"strconv"
"strings"
"unicode/utf8"
)

type imMarkdownContext struct {
baseURL string
blockquoteDepth int
// headingSeq tracks running counters for auto-numbered headings
// (seq="auto"). It is a shared pointer so sibling and nested headings
// resolve against the same numbering state during one conversion.
headingSeq *imMarkdownHeadingSeq
}

type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
Expand Down Expand Up @@ -161,6 +166,9 @@ func imMarkdownBaseURLFromInput(raw string) (string, bool) {
}

func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
if imCtx.headingSeq == nil {
imCtx.headingSeq = &imMarkdownHeadingSeq{}
}
var out strings.Builder
for offset := 0; offset < len(content); {
// Scan only to the next XML-like opening tag. Plain Markdown text between
Expand Down Expand Up @@ -253,17 +261,75 @@ func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx im
}

func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return func(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if text == "" {
return ""
}
markdownLevel := level
// Prefer the server-provided seq-level for the heading depth; fall
// back to the <hN> tag level when seq-level is absent or "auto".
markdownLevel := resolveIMMarkdownHeadingLevel(attrs, level)
if markdownLevel > 6 {
markdownLevel = 6
}
return strings.Repeat("#", markdownLevel) + " " + text
prefix := resolveIMMarkdownHeadingSeq(attrs, markdownLevel, imCtx)
if prefix != "" {
prefix += " "
}
return strings.Repeat("#", markdownLevel) + " " + prefix + text
}
}

// imMarkdownHeadingSeq tracks running counters for auto-numbered headings so a
// nested document renders as 1., 1.1., 1.2., 2., etc. It mirrors the
// auto-numbering already applied to ordered list items, but heading counters
// are scoped per depth: advancing a shallower level resets all deeper counters.
type imMarkdownHeadingSeq struct {
counters [7]int // counters[1..6] are used; index 0 is unused
}

// resolveIMMarkdownHeadingLevel returns the heading depth used for markdown
// rendering and auto-numbering. It prefers an explicit server-provided
// seq-level attribute (e.g. seq-level="2"); when seq-level is absent
// or "auto", it falls back to the <hN> tag level. This preserves
// the seq-level metadata the server attaches to headings (see issue #1781).
func resolveIMMarkdownHeadingLevel(attrs map[string]string, tagLevel int) int {
if lvl, err := strconv.Atoi(strings.TrimSpace(attrs["seq-level"])); err == nil && lvl > 0 {
return lvl
}
return tagLevel
}

// resolveIMMarkdownHeadingSeq returns the numbering prefix for a heading, or ""
// when no seq attribute is present (the heading is not auto-numbered). An
// explicit seq value is used verbatim (mirroring list-item handling); seq="auto"
// resolves against the running counter state for the heading's level.
func resolveIMMarkdownHeadingSeq(attrs map[string]string, level int, imCtx imMarkdownContext) string {
seq := strings.TrimSpace(attrs["seq"])
if seq == "" {
return ""
}
if seq != "auto" {
return strings.TrimSuffix(seq, ".") + "."
}
if imCtx.headingSeq == nil {
return ""
}
if level < 1 {
level = 1
}
if level > 6 {
level = 6
}
imCtx.headingSeq.counters[level]++
for deeper := level + 1; deeper <= 6; deeper++ {
imCtx.headingSeq.counters[deeper] = 0
}
parts := make([]string, 0, level)
for l := 1; l <= level; l++ {
parts = append(parts, strconv.Itoa(imCtx.headingSeq.counters[l]))
}
return strings.Join(parts, ".") + "."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
Expand Down
76 changes: 76 additions & 0 deletions shortcuts/doc/docs_fetch_im_markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,82 @@ func TestConvertToIMMarkdownParagraphHeadingAndListItemEdges(t *testing.T) {
})
}

func TestConvertToIMMarkdownHeadingSeq(t *testing.T) {
t.Parallel()

// Multiple headings in one document share numbering state; advancing a
// shallower level resets the deeper counters (1., 1.1., 1.2., 2., 2.1.).
imCtx := imMarkdownContext{baseURL: "https://larkoffice.com"}
doc := strings.Join([]string{
`<h1 seq="auto" seq-level="auto">Section A</h1>`,
`<h2 seq="auto" seq-level="auto">Section A.1</h2>`,
`<h2 seq="auto" seq-level="auto">Section A.2</h2>`,
`<h1 seq="auto" seq-level="auto">Section B</h1>`,
`<h2 seq="auto" seq-level="auto">Section B.1</h2>`,
}, "\n")
want := strings.Join([]string{
`# 1. Section A`,
`## 1.1. Section A.1`,
`## 1.2. Section A.2`,
`# 2. Section B`,
`## 2.1. Section B.1`,
}, "\n")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if got := convertToIMMarkdown(doc, imCtx); got != want {
t.Fatalf("convertToIMMarkdown() = %q, want %q", got, want)
}

// Headings without seq, with explicit seq, and empty headings keep their
// existing (non-numbered or verbatim) behavior.
assertIMMarkdownCases(t, []imMarkdownCase{
{
name: "heading without seq has no prefix",
input: `<h1>Plain</h1>`,
want: `# Plain`,
},
{
name: "explicit seq is used verbatim",
input: `<h2 seq="3">Custom</h2>`,
want: `## 3. Custom`,
},
{
name: "explicit seq keeps trailing dot",
input: `<h3 seq="2.">Custom</h3>`,
want: `### 2. Custom`,
},
{
name: "empty auto heading stays empty",
input: `<h1 seq="auto" seq-level="auto"> </h1>`,
want: ``,
},
})
}

func TestConvertToIMMarkdownHeadingSeqLevel(t *testing.T) {
t.Parallel()

// An explicit server-provided seq-level drives the heading depth and
// overrides the <hN> tag; when seq-level is absent it falls back
// to the tag level. These directly exercise the seq-level attribute
// (see issue #1781), independent of the auto-numbering counter.
assertIMMarkdownCases(t, []imMarkdownCase{
{
name: "explicit seq-level overrides tag level (h1 -> 3)",
input: `<h1 seq="1" seq-level="3">Section</h1>`,
want: `### 1. Section`,
},
{
name: "explicit seq-level=2 on h1",
input: `<h1 seq="9" seq-level="2">Section</h1>`,
want: `## 9. Section`,
},
{
name: "seq-level absent falls back to tag level",
input: `<h1 seq="4">Section</h1>`,
want: `# 4. Section`,
},
})
}

func TestConvertToIMMarkdownGridAndColumn(t *testing.T) {
t.Parallel()

Expand Down
18 changes: 18 additions & 0 deletions shortcuts/doc/docs_fetch_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
if warning := addFetchMarkdownHeadingSeqWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
if isIMMarkdownFetch(runtime) {
applyFetchIMMarkdown(data, runtime.Str("doc"))
}
Expand Down Expand Up @@ -272,6 +275,21 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str
return warning
}

// addFetchMarkdownHeadingSeqWarning warns that heading auto-numbering
// (seq/seq-level) cannot be preserved in plain --doc-format markdown output.
// The server returns literal markdown and drops seq/seq-level server-side, so it
// is unrecoverable locally; callers must re-fetch with --doc-format xml or
// --doc-format im-markdown. This is a static format-level note: for plain
// markdown we cannot detect whether the source document actually uses numbering.
func addFetchMarkdownHeadingSeqWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
if strings.TrimSpace(runtime.Str("doc-format")) != "markdown" {
return ""
}
warning := "heading auto-numbering (seq/seq-level) is not represented in plain --doc-format markdown output; re-fetch with --doc-format xml or --doc-format im-markdown to preserve heading numbering"
appendDocWarning(data, warning)
return warning
}

// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := effectiveFetchReadMode(runtime)
Expand Down
21 changes: 17 additions & 4 deletions shortcuts/doc/docs_fetch_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,11 +755,24 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
}
data, _ := envelope["data"].(map[string]interface{})
warnings, _ := data["warnings"].([]interface{})
if len(warnings) != 1 {
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
if len(warnings) != 2 {
t.Fatalf("warnings = %#v, want downgrade and heading-seq warnings", data["warnings"])
}
gotDowngrade, gotHeading := false, false
for _, w := range warnings {
s, _ := w.(string)
if strings.Contains(s, "returning markdown output") && strings.Contains(s, "ignoring the unsupported detail option") {
gotDowngrade = true
}
if strings.Contains(s, "heading auto-numbering") {
gotHeading = true
}
}
if !gotDowngrade {
t.Fatalf("missing detail downgrade warning in: %#v", warnings)
}
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
t.Fatalf("unexpected warning: %q", got)
if !gotHeading {
t.Fatalf("missing heading-seq warning in: %#v", warnings)
}
}

Expand Down
Loading