Skip to content
Merged
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
3 changes: 3 additions & 0 deletions shortcuts/im/coverage_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
if !strings.Contains(got, `"tag":"md"`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
}
if !strings.Contains(got, `"tag":"text"`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
}
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
}
Expand Down
163 changes: 140 additions & 23 deletions shortcuts/im/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,25 +624,49 @@ func readMp4Duration(f *os.File, fileSize int64) int64 {
// 5. Compress excess blank lines
// 6. Strip invalid image references (keep only img_xxx keys)
var (
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
)

func optimizeMarkdownStyle(text string) string {
const mark = "___CB_"
const (
markdownCodeBlockPlaceholder = "___CB_"
postBlankLinePlaceholder = "\u200B"
)

type markdownPart struct {
text string
newlineCount int
isSeparator bool
}

func protectMarkdownCodeBlocks(text string) (string, []string) {
var codeBlocks []string
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
idx := len(codeBlocks)
codeBlocks = append(codeBlocks, m)
return fmt.Sprintf("%s%d___", mark, idx)
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
})
return protected, codeBlocks
}

func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
restored := text
for i, block := range codeBlocks {
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
}
return restored
}

func optimizeMarkdownStyle(text string) string {
r, codeBlocks := protectMarkdownCodeBlocks(text)

// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
if reHasH1toH3.MatchString(text) {
Expand All @@ -655,9 +679,7 @@ func optimizeMarkdownStyle(text string) string {
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
r = reTableAfter.ReplaceAllString(r, "$1\n")

for i, block := range codeBlocks {
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
}
r = restoreMarkdownCodeBlocks(r, codeBlocks)

r = reExcessNL.ReplaceAllString(r, "\n\n")

Expand All @@ -676,12 +698,109 @@ func optimizeMarkdownStyle(text string) string {
return r
}

func shouldUseSegmentedPost(markdown string) bool {
protected, _ := protectMarkdownCodeBlocks(markdown)
return reBlankLineSeparator.MatchString(protected)
}

func splitMarkdownByBlankLines(markdown string) []markdownPart {
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
if len(locs) == 0 {
return []markdownPart{{text: markdown}}
}

parts := make([]markdownPart, 0, len(locs)*2+1)
last := 0
for _, loc := range locs {
if loc[0] > last {
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
if content != "" {
parts = append(parts, markdownPart{text: content})
}
}
separator := protected[loc[0]:loc[1]]
parts = append(parts, markdownPart{
isSeparator: true,
newlineCount: strings.Count(separator, "\n"),
})
last = loc[1]
}

if last < len(protected) {
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
if content != "" {
parts = append(parts, markdownPart{text: content})
}
}

if len(parts) == 0 {
return []markdownPart{{text: markdown}}
}
return parts
}

func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
payload := map[string]interface{}{
"zh_cn": map[string]interface{}{
"content": content,
},
}
data, _ := json.Marshal(payload)
return string(data)
}

func buildSingleMDPost(markdown string) string {
return marshalMarkdownPostContent([][]map[string]interface{}{
{{
"tag": "md",
"text": optimizeMarkdownStyle(markdown),
}},
})
}

func buildSegmentedPost(markdown string) string {
parts := splitMarkdownByBlankLines(markdown)
content := make([][]map[string]interface{}, 0, len(parts))
for _, part := range parts {
if part.isSeparator {
for i := 1; i < part.newlineCount; i++ {
content = append(content, []map[string]interface{}{{
"tag": "text",
"text": postBlankLinePlaceholder,
}})
}
continue
}
if part.text == "" {
continue
}
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
if optimized == "" {
continue
}
content = append(content, []map[string]interface{}{{
"tag": "md",
"text": optimized,
}})
}
if len(content) == 0 {
return buildSingleMDPost(markdown)
}
return marshalMarkdownPostContent(content)
}

func buildMarkdownPostContent(markdown string) string {
if shouldUseSegmentedPost(markdown) {
return buildSegmentedPost(markdown)
}
return buildSingleMDPost(markdown)
}

// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
func wrapMarkdownAsPost(markdown string) string {
optimized := optimizeMarkdownStyle(markdown)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
return buildMarkdownPostContent(markdown)
}

var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
Expand Down Expand Up @@ -716,9 +835,7 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
// and wraps as post format JSON. Used by Execute (makes network calls).
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
optimized := optimizeMarkdownStyle(resolved)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
return buildMarkdownPostContent(resolved)
}

// resolveMarkdownImageURLs finds ![alt](https://...) in markdown, downloads each URL,
Expand Down
128 changes: 121 additions & 7 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"net/http"
"reflect"
Expand All @@ -17,6 +18,36 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

func decodePostContentForTest(t *testing.T, raw string) []interface{} {
t.Helper()

var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
}
locale, _ := payload["zh_cn"].(map[string]interface{})
content, _ := locale["content"].([]interface{})
if content == nil {
t.Fatalf("post content missing: %#v", payload)
}
return content
}

func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
t.Helper()

content := decodePostContentForTest(t, raw)
if idx >= len(content) {
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
}
paragraph, _ := content[idx].([]interface{})
if len(paragraph) != 1 {
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
}
node, _ := paragraph[0].(map[string]interface{})
return node
}

func TestNormalizeAtMentions(t *testing.T) {
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
got := normalizeAtMentions(input)
Expand Down Expand Up @@ -141,6 +172,16 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
}
}

func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n![alt](https://example.com/a.png)")
if !strings.Contains(content, `![alt](img_dryrun_1)`) {
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
}
if !strings.Contains(content, `"tag":"text"`) {
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
}
}

func TestResolveMediaContentWithoutUploads(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -332,15 +373,88 @@ func TestOptimizeMarkdownStyle(t *testing.T) {

func TestWrapMarkdownAsPost(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**")
// Should produce valid JSON with post structure
if !strings.Contains(got, `"tag":"md"`) {
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
content := decodePostContentForTest(t, got)
if len(content) != 1 {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
}
if !strings.Contains(got, `"zh_cn"`) {
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
node := decodePostParagraphForTest(t, got, 0)
if node["tag"] != "md" {
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
}
if node["text"] != "hello **world**" {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
}
}

func TestShouldUseSegmentedPost(t *testing.T) {
tests := []struct {
name string
markdown string
want bool
}{
{name: "single newline", markdown: "a\nb", want: false},
{name: "blank line", markdown: "a\n\nb", want: true},
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
}
})
}
}

func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
got := wrapMarkdownAsPost("a\n\nb")
content := decodePostContentForTest(t, got)
if len(content) != 3 {
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
}

first := decodePostParagraphForTest(t, got, 0)
if first["tag"] != "md" || first["text"] != "a" {
t.Fatalf("first paragraph = %#v, want md/a", first)
}

second := decodePostParagraphForTest(t, got, 1)
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
}

third := decodePostParagraphForTest(t, got, 2)
if third["tag"] != "md" || third["text"] != "b" {
t.Fatalf("third paragraph = %#v, want md/b", third)
}
}

func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
got := wrapMarkdownAsPost("a\n\n\nb")
content := decodePostContentForTest(t, got)
if len(content) != 4 {
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
}

for i := 1; i <= 2; i++ {
node := decodePostParagraphForTest(t, got, i)
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
}
}
}

func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
got := wrapMarkdownAsPost("a\n \nb")
content := decodePostContentForTest(t, got)
if len(content) != 3 {
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
}
if !strings.Contains(got, "hello **world**") {
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
node := decodePostParagraphForTest(t, got, 1)
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
}
}

Expand Down
Loading