diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 7c94127027..edc475d46b 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -8,6 +8,7 @@ import ( "html" "net/url" "regexp" + "strconv" "strings" "unicode/utf8" ) @@ -15,6 +16,10 @@ import ( 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 @@ -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 @@ -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 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 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, ".") + "." } func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 262c48ed34..b005b34f0e 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -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{ + `

Section A

`, + `

Section A.1

`, + `

Section A.2

`, + `

Section B

`, + `

Section B.1

`, + }, "\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") + 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: `

Plain

`, + want: `# Plain`, + }, + { + name: "explicit seq is used verbatim", + input: `

Custom

`, + want: `## 3. Custom`, + }, + { + name: "explicit seq keeps trailing dot", + input: `

Custom

`, + want: `### 2. Custom`, + }, + { + name: "empty auto heading stays empty", + input: `

`, + want: ``, + }, + }) +} + +func TestConvertToIMMarkdownHeadingSeqLevel(t *testing.T) { + t.Parallel() + + // An explicit server-provided seq-level drives the heading depth and + // overrides the 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: `

Section

`, + want: `### 1. Section`, + }, + { + name: "explicit seq-level=2 on h1", + input: `

Section

`, + want: `## 9. Section`, + }, + { + name: "seq-level absent falls back to tag level", + input: `

Section

`, + want: `# 4. Section`, + }, + }) +} + func TestConvertToIMMarkdownGridAndColumn(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index f8b43812e6..817ec95901 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -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")) } @@ -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) diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 68d2750ce7..1d991dc02f 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -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) } }