diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go index 10c2c8577..564bedf9e 100644 --- a/shortcuts/im/convert_lib/card.go +++ b/shortcuts/im/convert_lib/card.go @@ -68,6 +68,8 @@ func convertCard(raw string) string { if json.Unmarshal([]byte(att), &attObj) == nil { c.attachment = attObj } + } else if attObj, ok := parsed["json_attachment"].(cardObj); ok { + c.attachment = attObj } schema := 0 if s, ok := parsed["card_schema"].(float64); ok { @@ -170,8 +172,12 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string { header, _ := card["header"].(cardObj) title := "" + subtitle := "" + headerTags := "" if header != nil { title = c.extractHeaderTitle(header) + subtitle = c.extractHeaderSubtitle(header) + headerTags = c.extractHeaderTags(header) } bodyContent := "" @@ -180,13 +186,19 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string { } var sb strings.Builder - if title != "" { - sb.WriteString("\n") + if title != "" && subtitle != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title), cardEscapeAttr(subtitle))) + } else if title != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title))) + } else if subtitle != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(subtitle))) } else { sb.WriteString("\n") } + if headerTags != "" { + sb.WriteString(headerTags) + sb.WriteString("\n") + } if bodyContent != "" { sb.WriteString(bodyContent) sb.WriteString("\n") @@ -207,6 +219,45 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string { return "" } +func (c *cardConverter) extractHeaderSubtitle(header cardObj) string { + if prop, ok := header["property"].(cardObj); ok { + if subtitleElem, ok := prop["subtitle"]; ok { + return c.extractTextContent(subtitleElem) + } + } + if subtitleElem, ok := header["subtitle"]; ok { + return c.extractTextContent(subtitleElem) + } + return "" +} + +func (c *cardConverter) extractHeaderTags(header cardObj) string { + var prop cardObj + if p, ok := header["property"].(cardObj); ok { + prop = p + } else { + prop = header + } + tagList, ok := prop["textTagList"].([]interface{}) + if !ok || len(tagList) == 0 { + return "" + } + var tags []string + for _, tag := range tagList { + tm, ok := tag.(cardObj) + if !ok { + continue + } + if text := c.convertElement(tm, 0); text != "" { + tags = append(tags, text) + } + } + if len(tags) == 0 { + return "" + } + return strings.Join(tags, " ") +} + func (c *cardConverter) convertBody(body cardObj) string { var elements []interface{} @@ -453,8 +504,11 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string { if textElem, ok := prop["text"].(cardObj); ok { if text := c.convertElement(textElem, 0); text != "" { - if textSize, _ := textElem["text_size"].(string); textSize == "notation" { - text = "๐Ÿ“ " + text + textProp := c.extractProperty(textElem) + if textStyle, ok := textProp["textStyle"].(cardObj); ok { + if size, _ := textStyle["size"].(string); size == "notation" { + text = "๐Ÿ“ " + text + } } results = append(results, text) } @@ -532,7 +586,14 @@ func (c *cardConverter) convertEmoji(prop cardObj) string { } func (c *cardConverter) convertLocalDatetime(prop cardObj) string { - if ms, ok := prop["milliseconds"].(string); ok && ms != "" { + var ms string + switch v := prop["milliseconds"].(type) { + case string: + ms = v + case float64: + ms = strconv.FormatInt(int64(v), 10) + } + if ms != "" { if formatted := cardFormatMillisToISO8601(ms); formatted != "" { return formatted } @@ -763,22 +824,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string { } } - shouldExpand := expanded || c.mode == cardModeDetailed - if shouldExpand { - var sb strings.Builder - sb.WriteString("โ–ผ " + title + "\n") - if elements, ok := prop["elements"].([]interface{}); ok { - content := c.convertElements(elements, 1) - for _, line := range strings.Split(content, "\n") { - if line != "" { - sb.WriteString(" " + line + "\n") - } + indicator := "โ–ถ" + if expanded { + indicator = "โ–ผ" + } + var sb strings.Builder + sb.WriteString(indicator + " " + title + "\n") + if elements, ok := prop["elements"].([]interface{}); ok { + content := c.convertElements(elements, 1) + for _, line := range strings.Split(content, "\n") { + if line != "" { + sb.WriteString(" " + line + "\n") } } - sb.WriteString("โ–ฒ") - return sb.String() } - return "โ–ถ " + title + sb.WriteString("โ–ฒ") + return sb.String() } func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string { @@ -826,10 +887,17 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { } disabled, _ := prop["disabled"].(bool) - if disabled && c.mode == cardModeConcise { - return fmt.Sprintf("[%s โœ—]", buttonText) + if disabled { + result := fmt.Sprintf("[%s โœ—]", buttonText) + if tips, ok := prop["disabledTips"].(cardObj); ok { + if tipsText := c.extractTextContent(tips); tipsText != "" { + result += fmt.Sprintf("(tips:\"%s\")", tipsText) + } + } + return result } + result := fmt.Sprintf("[%s]", buttonText) if actions, ok := prop["actions"].([]interface{}); ok { for _, action := range actions { am, ok := action.(cardObj) @@ -839,24 +907,32 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { if am["type"] == "open_url" { if ad, ok := am["action"].(cardObj); ok { if urlStr, ok := ad["url"].(string); ok && urlStr != "" { - return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + break } } } } } - if disabled && c.mode == cardModeDetailed { - result := fmt.Sprintf("[%s โœ—]", buttonText) - if tips, ok := prop["disabledTips"].(cardObj); ok { - if tipsText := c.extractTextContent(tips); tipsText != "" { - result += fmt.Sprintf("(tips:\"%s\")", tipsText) + if confirmObj, ok := prop["confirm"].(cardObj); ok { + var parts []string + if titleElem, ok := confirmObj["title"]; ok { + if t := c.extractTextContent(titleElem); t != "" { + parts = append(parts, t) } } - return result + if textElem, ok := confirmObj["text"]; ok { + if t := c.extractTextContent(textElem); t != "" { + parts = append(parts, t) + } + } + if len(parts) > 0 { + result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": ")) + } } - return fmt.Sprintf("[%s]", buttonText) + return result } func (c *cardConverter) convertActions(prop cardObj) string { @@ -888,11 +964,33 @@ func (c *cardConverter) convertOverflow(prop cardObj) string { if !ok { continue } + text := "" if textElem, ok := om["text"].(cardObj); ok { - if text := c.extractTextContent(textElem); text != "" { - optTexts = append(optTexts, text) + text = c.extractTextContent(textElem) + } + if text == "" { + continue + } + urlStr := "" + if actions, ok := om["actions"].([]interface{}); ok { + for _, a := range actions { + am, ok := a.(cardObj) + if !ok { + continue + } + if am["type"] == "open_url" { + if ad, ok := am["action"].(cardObj); ok { + urlStr, _ = ad["url"].(string) + } + } } } + if urlStr != "" { + text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr) + } else if value, _ := om["value"].(string); value != "" { + text += "(" + value + ")" + } + optTexts = append(optTexts, text) } return "โ‹ฎ " + strings.Join(optTexts, ", ") } @@ -932,17 +1030,20 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str if !ok { continue } + value, _ := om["value"].(string) optText := "" if textElem, ok := om["text"].(cardObj); ok { optText = c.extractTextContent(textElem) } if optText == "" { - optText, _ = om["value"].(string) + optText = c.lookupOptionUserName(value) + } + if optText == "" { + optText = value } if optText == "" { continue } - value, _ := om["value"].(string) if selectedValues[value] { optText = "โœ“" + optText hasSelected = true @@ -963,17 +1064,15 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str } result := "{" + strings.Join(optionTexts, " / ") + "}" - if c.mode == cardModeDetailed { - var attrs []string - if isMulti { - attrs = append(attrs, "multi") - } - if strings.Contains(id, "person") { - attrs = append(attrs, "type:person") - } - if len(attrs) > 0 { - result += "(" + strings.Join(attrs, " ") + ")" - } + var attrs []string + if isMulti { + attrs = append(attrs, "multi") + } + if c.mode == cardModeDetailed && strings.Contains(id, "person") { + attrs = append(attrs, "type:person") + } + if len(attrs) > 0 { + result += "(" + strings.Join(attrs, " ") + ")" } return result } @@ -999,6 +1098,17 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string { } value, _ := om["value"].(string) text := fmt.Sprintf("๐Ÿ–ผ๏ธ Image %d", i+1) + if value != "" { + text += "(" + value + ")" + } + if imageID, ok := om["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + text += "(img_key:" + originKey + ")" + } else if imgToken != "" { + text += "(img_token:" + imgToken + ")" + } + } if selectedValues[value] { text = "โœ“" + text } @@ -1101,13 +1211,14 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string { } result := "๐Ÿ–ผ๏ธ " + alt - if c.mode == cardModeDetailed { - if imageID, ok := prop["imageID"].(string); ok && imageID != "" { - if token := c.getImageToken(imageID); token != "" { - result += "(img_token:" + token + ")" - } else { - result += "(img_key:" + imageID + ")" - } + if imageID, ok := prop["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + result += "(img_key:" + originKey + ")" + } else if imgToken != "" { + result += "(img_token:" + imgToken + ")" + } else { + result += "(img_key:" + imageID + ")" } } return result @@ -1119,20 +1230,25 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string { return "" } result := fmt.Sprintf("๐Ÿ–ผ๏ธ %d image(s)", len(imgList)) - if c.mode == cardModeDetailed { - var keys []string - for _, img := range imgList { - im, ok := img.(cardObj) - if !ok { - continue - } - if imageID, ok := im["imageID"].(string); ok && imageID != "" { + var keys []string + for _, img := range imgList { + im, ok := img.(cardObj) + if !ok { + continue + } + if imageID, ok := im["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + keys = append(keys, originKey) + } else if imgToken != "" { + keys = append(keys, imgToken) + } else { keys = append(keys, imageID) } } - if len(keys) > 0 { - result += "(keys:" + strings.Join(keys, ",") + ")" - } + } + if len(keys) > 0 { + result += "(keys:" + strings.Join(keys, ",") + ")" } return result } @@ -1150,7 +1266,11 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string { if ct, ok := chartSpec["type"].(string); ok && ct != "" { chartType = ct if typeName, ok := cardChartTypeNames[ct]; ok { - title += typeName + if title != "Chart" { + title += " (" + typeName + ")" + } else { + title = typeName + } } } } @@ -1168,12 +1288,25 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri if !ok { return "" } - dataObj, ok := chartSpec["data"].(cardObj) - if !ok { - return "" + + // VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]). + // Older/object format: data is a map with a "values" key directly. + var values []interface{} + switch d := chartSpec["data"].(type) { + case cardObj: + if v, ok := d["values"].([]interface{}); ok { + values = v + } + case []interface{}: + for _, series := range d { + if sm, ok := series.(cardObj); ok { + if v, ok := sm["values"].([]interface{}); ok { + values = append(values, v...) + } + } + } } - values, ok := dataObj["values"].([]interface{}) - if !ok || len(values) == 0 { + if len(values) == 0 { return "" } @@ -1218,28 +1351,24 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri func (c *cardConverter) convertAudio(prop cardObj, _ string) string { result := "๐ŸŽต Audio" - if c.mode == cardModeDetailed { - fileID, _ := prop["fileID"].(string) - if fileID == "" { - fileID, _ = prop["audioID"].(string) - } - if fileID != "" { - result += "(key:" + fileID + ")" - } + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["audioID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" } return result } func (c *cardConverter) convertVideo(prop cardObj, _ string) string { result := "๐ŸŽฌ Video" - if c.mode == cardModeDetailed { - fileID, _ := prop["fileID"].(string) - if fileID == "" { - fileID, _ = prop["videoID"].(string) - } - if fileID != "" { - result += "(key:" + fileID + ")" - } + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["videoID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" } return result } @@ -1297,9 +1426,14 @@ func (c *cardConverter) convertTable(prop cardObj) string { func (c *cardConverter) extractTableCellValue(data interface{}) string { switch v := data.(type) { case string: + // Lark API serialises array-type cell data as a Go-format string like + // "[map[text:VIP] map[text:Premium]]". Detect and extract text values. + if texts := goMapArrayTexts(v); len(texts) > 0 { + return strings.Join(texts, ", ") + } return v case float64: - return strconv.FormatFloat(v, 'f', 2, 64) + return strconv.FormatFloat(v, 'f', -1, 64) case []interface{}: var texts []string for _, item := range v { @@ -1320,6 +1454,38 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string { } } +// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string, +// e.g. "[map[text:VIP] map[text:Premium]]" โ†’ ["VIP", "Premium"]. +// Returns nil if the string doesn't look like this format. +func goMapArrayTexts(s string) []string { + if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") { + return nil + } + const key = "text:" + var texts []string + rest := s + for { + idx := strings.Index(rest, key) + if idx < 0 { + break + } + after := rest[idx+len(key):] + end := strings.IndexAny(after, " ]") + var val string + if end < 0 { + val = after + rest = "" + } else { + val = after[:end] + rest = after[end:] + } + if val != "" { + texts = append(texts, val) + } + } + return texts +} + func (c *cardConverter) convertPerson(prop cardObj, _ string) string { userID, _ := prop["userID"].(string) if userID == "" { @@ -1333,14 +1499,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string { } if personName != "" { if c.mode == cardModeDetailed { - return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + return fmt.Sprintf("%s(open_id:%s)", personName, userID) } - return "@" + personName + return personName } if c.mode == cardModeDetailed { - return fmt.Sprintf("@user(open_id:%s)", userID) + return fmt.Sprintf("user(open_id:%s)", userID) } - return "@" + userID + return userID } // convertPersonV1 handles the v1 card schema person element. @@ -1356,14 +1522,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string { personName := c.lookupPersonName(userID) if personName != "" { if c.mode == cardModeDetailed { - return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + return fmt.Sprintf("%s(open_id:%s)", personName, userID) } - return "@" + personName + return personName } if c.mode == cardModeDetailed { - return fmt.Sprintf("@user(open_id:%s)", userID) + return fmt.Sprintf("user(open_id:%s)", userID) } - return "@" + userID + return userID } func (c *cardConverter) convertPersonList(prop cardObj) string { @@ -1378,10 +1544,21 @@ func (c *cardConverter) convertPersonList(prop cardObj) string { continue } personID, _ := pm["id"].(string) - if c.mode == cardModeDetailed && personID != "" { - names = append(names, fmt.Sprintf("@user(id:%s)", personID)) + personName := c.lookupPersonName(personID) + if personName != "" { + if c.mode == cardModeDetailed { + names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID)) + } else { + names = append(names, personName) + } + } else if personID != "" { + if c.mode == cardModeDetailed { + names = append(names, fmt.Sprintf("user(id:%s)", personID)) + } else { + names = append(names, personID) + } } else { - names = append(names, "@user") + names = append(names, "user") } } return strings.Join(names, ", ") @@ -1389,8 +1566,15 @@ func (c *cardConverter) convertPersonList(prop cardObj) string { func (c *cardConverter) convertAvatar(prop cardObj, _ string) string { userID, _ := prop["userID"].(string) + personName := c.lookupPersonName(userID) + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("๐Ÿ‘ค %s(open_id:%s)", personName, userID) + } + return "๐Ÿ‘ค " + personName + } result := "๐Ÿ‘ค" - if c.mode == cardModeDetailed && userID != "" { + if userID != "" { result += "(id:" + userID + ")" } return result @@ -1445,20 +1629,33 @@ func (c *cardConverter) lookupPersonName(userID string) string { return "" } -func (c *cardConverter) getImageToken(imageID string) string { +func (c *cardConverter) lookupOptionUserName(userID string) string { if c.attachment == nil { return "" } - if images, ok := c.attachment["images"].(cardObj); ok { - if imageInfo, ok := images[imageID].(cardObj); ok { - if token, ok := imageInfo["token"].(string); ok { - return token + if optUsers, ok := c.attachment["option_users"].(cardObj); ok { + if userInfo, ok := optUsers[userID].(cardObj); ok { + if content, ok := userInfo["content"].(string); ok { + return content } } } return "" } +func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) { + if c.attachment == nil { + return "", "" + } + if images, ok := c.attachment["images"].(cardObj); ok { + if imageInfo, ok := images[imageID].(cardObj); ok { + originKey, _ = imageInfo["origin_key"].(string) + token, _ = imageInfo["token"].(string) + } + } + return originKey, token +} + type cardTextStyle struct { bold bool italic bool diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go index bb011f987..b05eb9be0 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/shortcuts/im/convert_lib/card_test.go @@ -19,7 +19,11 @@ func newTestCardConverter(mode cardMode) *cardConverter { "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, }, "images": cardObj{ - "img_1": cardObj{"token": "img_tok_1"}, + "img_1": cardObj{"token": "img_tok_1", "origin_key": "img_v3_test_key1"}, + }, + "option_users": cardObj{ + "opt_alice": cardObj{"content": "Alice"}, + "opt_bob": cardObj{"content": "Bob"}, }, }, } @@ -39,6 +43,12 @@ func TestConvertCard(t *testing.T) { if gotLegacy != wantLegacy { t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) } + + // C008 root cause: json_attachment as object (not string) โ€” persons resolved via attachment + withObjAttachment := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Title\"}},\"body\":{\"elements\":[{\"tag\":\"person\",\"property\":{\"userID\":\"ou_1\"}}]}}","json_attachment":{"persons":{"ou_1":{"content":"Alice"}}}}` + if got := convertCard(withObjAttachment); !strings.Contains(got, "Alice") { + t.Fatalf("convertCard(json_attachment object) = %q, want person name resolved", got) + } } func TestCardUtilityFunctions(t *testing.T) { @@ -75,7 +85,7 @@ func TestCardConverterMethods(t *testing.T) { t.Fatalf("convertMarkdownV1() = %q", got) } if got := c.convertDiv(cardObj{ - "text": cardObj{"tag": "text", "property": cardObj{"content": "Title"}, "text_size": "notation"}, + "text": cardObj{"tag": "text", "property": cardObj{"content": "Title", "textStyle": cardObj{"size": "notation"}}}, "fields": []interface{}{cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Field 1"}}}}, "extra": cardObj{"tag": "text", "property": cardObj{"content": "Extra"}}, }, ""); got != "๐Ÿ“ Title\nField 1\nExtra" { @@ -164,9 +174,22 @@ func TestCardConverterMethods(t *testing.T) { }, "select_person", true); got != "{โœ“Alice / Bob}(multi type:person)" { t.Fatalf("convertSelect() = %q", got) } - if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{๐Ÿ–ผ๏ธ Image 1 / โœ“๐Ÿ–ผ๏ธ Image 2}" { + // select_person with no option text: names resolved from option_users attachment + if got := c.convertSelect(cardObj{ + "options": []interface{}{ + cardObj{"value": "opt_alice"}, + cardObj{"value": "opt_bob"}, + }, + "selectedValues": []interface{}{"opt_alice"}, + }, "select_person", true); got != "{โœ“Alice / Bob}(multi type:person)" { + t.Fatalf("convertSelect(person no-text) = %q", got) + } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{๐Ÿ–ผ๏ธ Image 1(1) / โœ“๐Ÿ–ผ๏ธ Image 2(2)}" { t.Fatalf("convertSelectImg() = %q", got) } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "opt_a", "imageID": "img_1"}, cardObj{"value": "opt_b"}}, "selectedValues": []interface{}{"opt_a"}}, ""); got != "{โœ“๐Ÿ–ผ๏ธ Image 1(opt_a)(img_key:img_v3_test_key1) / ๐Ÿ–ผ๏ธ Image 2(opt_b)}" { + t.Fatalf("convertSelectImg(with imageID) = %q", got) + } if got := c.convertInput(cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}, ""); got != "Reason: Type..." { t.Fatalf("convertInput() = %q", got) } @@ -176,10 +199,10 @@ func TestCardConverterMethods(t *testing.T) { if got := c.convertChecker(cardObj{"checked": true, "text": cardObj{"content": "Done"}}, "chk_1"); got != "[x] Done(id:chk_1)" { t.Fatalf("convertChecker() = %q", got) } - if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "๐Ÿ–ผ๏ธ Poster(img_token:img_tok_1)" { + if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "๐Ÿ–ผ๏ธ Poster(img_key:img_v3_test_key1)" { t.Fatalf("convertImage() = %q", got) } - if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "๐Ÿ–ผ๏ธ 2 image(s)(keys:img_1,img_2)" { + if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "๐Ÿ–ผ๏ธ 2 image(s)(keys:img_v3_test_key1,img_2)" { t.Fatalf("convertImgCombination() = %q", got) } if got := c.convertChart(cardObj{"chartSpec": cardObj{ @@ -191,7 +214,7 @@ func TestCardConverterMethods(t *testing.T) { cardObj{"month": "Jan", "value": 10}, cardObj{"month": "Feb", "value": 20}, }}, - }}, ""); got != "๐Ÿ“Š SalesBar chart\nSummary: Jan:10, Feb:20" { + }}, ""); got != "๐Ÿ“Š Sales (Bar chart)\nSummary: Jan:10, Feb:20" { t.Fatalf("convertChart() = %q", got) } if got := c.convertAudio(cardObj{"fileID": "audio_1"}, ""); got != "๐ŸŽต Audio(key:audio_1)" { @@ -208,25 +231,28 @@ func TestCardConverterMethods(t *testing.T) { "rows": []interface{}{ cardObj{ "name": cardObj{"data": "Alice"}, - "score": cardObj{"data": float64(95.5)}, + "score": cardObj{"data": "95.5"}, }, }, - }); got != "| Name | Score |\n|------|------|\n| Alice | 95.50 |" { + }); got != "| Name | Score |\n|------|------|\n| Alice | 95.5 |" { t.Fatalf("convertTable() = %q", got) } if got := c.extractTableCellValue([]interface{}{cardObj{"text": "Tag 1"}, cardObj{"text": "Tag 2"}}); got != "ใ€ŒTag 1ใ€ ใ€ŒTag 2ใ€" { t.Fatalf("extractTableCellValue() = %q", got) } - if got := c.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + if got := c.extractTableCellValue("[map[text:VIP] map[text:Premium]]"); got != "VIP, Premium" { + t.Fatalf("extractTableCellValue(go-format array) = %q", got) + } + if got := c.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "Alice(open_id:ou_person)" { t.Fatalf("convertPerson() = %q", got) } - if got := c.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + if got := c.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "Alice(open_id:ou_person)" { t.Fatalf("convertPersonV1() = %q", got) } - if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "u2"}}}); got != "@user(id:u1), @user(id:u2)" { + if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "ou_person"}}}); got != "user(id:u1), Alice(open_id:ou_person)" { t.Fatalf("convertPersonList() = %q", got) } - if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "๐Ÿ‘ค(id:ou_person)" { + if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "๐Ÿ‘ค Alice(open_id:ou_person)" { t.Fatalf("convertAvatar() = %q", got) } if got := c.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { @@ -241,6 +267,39 @@ func TestCardConverterMethods(t *testing.T) { if got := (interactiveConverter{}).Convert(&ConvertContext{RawContent: `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"inside\"}}]}}"}`}); got != "\ninside\n" { t.Fatalf("interactiveConverter.Convert() = %q", got) } + + // C001: collapsible panel in concise mode (collapsed) must still render content + cc := newTestCardConverter(cardModeConcise) + if got := cc.convertCollapsiblePanel(cardObj{ + "expanded": false, + "header": cardObj{"title": cardObj{"content": "Details"}}, + "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "hidden info"}}}, + }, ""); !strings.Contains(got, "hidden info") { + t.Fatalf("convertCollapsiblePanel(concise,collapsed) = %q, want content rendered", got) + } + + // C002: extractHeaderSubtitle + if got := c.extractHeaderSubtitle(cardObj{"property": cardObj{ + "subtitle": cardObj{"property": cardObj{"content": "Q3 Budget"}}, + }}); got != "Q3 Budget" { + t.Fatalf("extractHeaderSubtitle() = %q", got) + } + + // C003: extractHeaderTags + if got := c.extractHeaderTags(cardObj{"textTagList": []interface{}{ + cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Approved"}}}, + }}); got != "ใ€ŒApprovedใ€" { + t.Fatalf("extractHeaderTags() = %q", got) + } + + // C007: convertButton disabled with disabledTips + if got := c.convertButton(cardObj{ + "text": cardObj{"content": "Submit"}, + "disabled": true, + "disabledTips": cardObj{"content": "Only managers can submit"}, + }, ""); got != "[Submit โœ—](tips:\"Only managers can submit\")" { + t.Fatalf("convertButton(disabled+tips) = %q", got) + } } func TestCardConverterExtractTextHelpers(t *testing.T) { @@ -302,7 +361,7 @@ func TestCardConverterDispatch(t *testing.T) { cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}}, cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}}, }}}, want: "[A] [B]"}, - {name: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "@Alice(open_id:ou_person)"}, + {name: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "Alice(open_id:ou_person)"}, {name: "at", elem: cardObj{"tag": "at", "property": cardObj{"userID": "ou_at"}}, want: "@Bob(user_id:u_bob)"}, {name: "at all", elem: cardObj{"tag": "at_all"}, want: "@everyone"}, {name: "actions", elem: cardObj{"tag": "actions", "property": cardObj{"actions": []interface{}{ @@ -312,7 +371,7 @@ func TestCardConverterDispatch(t *testing.T) { {name: "input", elem: cardObj{"tag": "input", "property": cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}}, want: "Reason: Type..."}, {name: "date", elem: cardObj{"tag": "date_picker", "property": cardObj{"initialDate": "1710500000"}}, contains: "๐Ÿ“… "}, {name: "checker", elem: cardObj{"tag": "checker", "id": "chk_1", "property": cardObj{"checked": true, "text": cardObj{"content": "Done"}}}, want: "[x] Done(id:chk_1)"}, - {name: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "๐Ÿ–ผ๏ธ Poster(img_token:img_tok_1)"}, + {name: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "๐Ÿ–ผ๏ธ Poster(img_key:img_v3_test_key1)"}, {name: "interactive", elem: cardObj{"tag": "interactive_container", "id": "cta_1", "property": cardObj{ "actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}},