mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add im-markdown output for doc fetch (#1550)
* feat: add docs im-markdown fetch format * refactor: tune docs im-markdown conversion * test: expand docs im-markdown conversion coverage * refactor: simplify docs im-markdown handlers * test: cover docs im-markdown edge cases * fix: expand doc im markdown tag downgrades * fix: preserve blockquote paragraph breaks * fix: handle im markdown nested tables and urls * docs: document im markdown skill usage * test: cover doc im markdown fetch * test: strengthen doc fetch error coverage * fix: fetch doc skill typo
This commit is contained in:
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
@@ -0,0 +1,861 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type imMarkdownContext struct {
|
||||
baseURL string
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
|
||||
|
||||
type imMarkdownTagHandler struct {
|
||||
closeRE *regexp.Regexp
|
||||
handle imMarkdownHandleFunc
|
||||
}
|
||||
|
||||
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
|
||||
imMarkdownHandlers[tag] = imMarkdownTagHandler{
|
||||
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
|
||||
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
|
||||
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
|
||||
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
|
||||
)
|
||||
|
||||
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
|
||||
|
||||
func init() {
|
||||
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
|
||||
for level := 1; level <= 9; level++ {
|
||||
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
|
||||
}
|
||||
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
|
||||
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
|
||||
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
|
||||
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
|
||||
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
|
||||
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
|
||||
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
|
||||
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
|
||||
registerIMMarkdownHandler("table", handleIMMarkdownTable)
|
||||
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
|
||||
registerIMMarkdownHandler("code", handleIMMarkdownCode)
|
||||
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
|
||||
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
|
||||
registerIMMarkdownHandler("img", handleIMMarkdownImage)
|
||||
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("source", handleIMMarkdownSource)
|
||||
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
|
||||
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
|
||||
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
|
||||
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
|
||||
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
|
||||
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
|
||||
}
|
||||
|
||||
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
|
||||
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
|
||||
}
|
||||
|
||||
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
|
||||
}
|
||||
|
||||
func newIMMarkdownContext(docInput string) imMarkdownContext {
|
||||
base := "https://larkoffice.com"
|
||||
raw := strings.TrimSpace(docInput)
|
||||
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
|
||||
base = extracted
|
||||
}
|
||||
return imMarkdownContext{baseURL: base}
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
|
||||
c.blockquoteDepth++
|
||||
return c
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) inBlockquote() bool {
|
||||
return c.blockquoteDepth > 0
|
||||
}
|
||||
|
||||
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
|
||||
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
|
||||
// intentionally strips host information, so it cannot serve this formatting path.
|
||||
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := strings.Trim(raw[:idx], "/")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
|
||||
return "https://" + u.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset := 0; offset < len(content); {
|
||||
// Scan only to the next XML-like opening tag. Plain Markdown text between
|
||||
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
|
||||
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
|
||||
handler, ok := imMarkdownHandlers[tag]
|
||||
if !ok {
|
||||
// Unknown tags are left intact. im-markdown only downgrades tags with
|
||||
// explicit handlers so future server output does not get guessed at.
|
||||
out.WriteString(content[offset:openEnd])
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(content[offset:start])
|
||||
opening := content[start:openEnd]
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
out.WriteString(handler.handle(opening, "", attrs, imCtx))
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the handler's precompiled close regexp to find the matching end tag.
|
||||
// Depth tracking keeps nested same-name containers paired correctly.
|
||||
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
|
||||
if !found {
|
||||
// Malformed or truncated fragments are preserved as-is from the opening
|
||||
// tag onward; do not drop content when the XML-ish structure is incomplete.
|
||||
out.WriteString(content[start:])
|
||||
break
|
||||
}
|
||||
segment := content[start:closeEnd]
|
||||
inner := content[openEnd:closeStart]
|
||||
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
|
||||
offset = closeEnd
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseIMMarkdownAttrs(opening string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
|
||||
value := match[2]
|
||||
if value == "" {
|
||||
value = match[3]
|
||||
}
|
||||
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSelfClosingIMMarkdownTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
|
||||
}
|
||||
|
||||
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "# " + text
|
||||
}
|
||||
|
||||
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
|
||||
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
markdownLevel := level
|
||||
if markdownLevel > 6 {
|
||||
markdownLevel = 6
|
||||
}
|
||||
return strings.Repeat("#", markdownLevel) + " " + text
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if imCtx.inBlockquote() {
|
||||
return body + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, false, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, true, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
prefix := "-"
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
emoji := strings.TrimSpace(attrs["emoji"])
|
||||
if emoji != "" {
|
||||
if body == "" {
|
||||
body = emoji
|
||||
} else {
|
||||
body = emoji + " " + body
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "---\n---"
|
||||
}
|
||||
return fmt.Sprintf("---\n%s\n---", body)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
lines[i] = ">"
|
||||
continue
|
||||
}
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
lang := strings.TrimSpace(attrs["lang"])
|
||||
code := strings.TrimSpace(inner)
|
||||
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
|
||||
code = match[1]
|
||||
}
|
||||
return imMarkdownFencedCode(html.UnescapeString(code), lang)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(markdownPlainText(inner))
|
||||
}
|
||||
|
||||
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
expr := strings.TrimSpace(markdownPlainText(inner))
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
|
||||
}
|
||||
|
||||
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return "---"
|
||||
}
|
||||
|
||||
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
|
||||
return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
|
||||
}
|
||||
|
||||
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
name := strings.TrimSpace(attrs["name"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return imMarkdownInlineCode(name)
|
||||
}
|
||||
|
||||
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
for _, attrName := range attrNames {
|
||||
if strings.TrimSpace(attrs[attrName]) != "" {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
token := strings.TrimSpace(attrs["token"])
|
||||
if token == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
label := "sheet"
|
||||
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
|
||||
label = "sheet " + sheetID
|
||||
}
|
||||
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
|
||||
if href == "" {
|
||||
return name
|
||||
}
|
||||
return markdownLink(name, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "**" + body + "**"
|
||||
}
|
||||
|
||||
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "*" + body + "*"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "~~" + body + "~~"
|
||||
}
|
||||
|
||||
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
|
||||
if href == "" {
|
||||
return text
|
||||
}
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
|
||||
case "user":
|
||||
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
|
||||
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
|
||||
if userID == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
|
||||
case "doc":
|
||||
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(title, href)
|
||||
}
|
||||
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
|
||||
if docID == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
|
||||
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
|
||||
case "citation":
|
||||
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
|
||||
}
|
||||
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
|
||||
default:
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
// Rows and cells are matched with tag-depth tracking instead of non-greedy
|
||||
// regex captures. A table nested inside a cell can contain its own </tr> and
|
||||
// </td>; treating those as the outer row/cell boundary corrupts the table.
|
||||
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
|
||||
if len(rowBodies) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rowBodies))
|
||||
for _, rowBody := range rowBodies {
|
||||
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
|
||||
if len(cellBodies) == 0 {
|
||||
continue
|
||||
}
|
||||
row := make([]string, 0, len(cellBodies))
|
||||
for _, cellBody := range cellBodies {
|
||||
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
|
||||
separator := make([]string, cols)
|
||||
for i := range separator {
|
||||
separator[i] = "-"
|
||||
}
|
||||
writeIMMarkdownTableRow(&out, separator)
|
||||
for _, row := range rows[1:] {
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// extractIMMarkdownElementBodies returns the inner content of each top-level
|
||||
// element matched by tagRE. tagRE must expose the optional closing slash as its
|
||||
// first capture group, matching the row/cell regexes above.
|
||||
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
|
||||
var bodies []string
|
||||
for offset := 0; offset < len(content); {
|
||||
loc := tagRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := content[openStart:openEnd]
|
||||
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
bodies = append(bodies, "")
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
bodies = append(bodies, content[openEnd:closeStart])
|
||||
offset = closeEnd
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeIMMarkdownTableCell(cell string) string {
|
||||
const brPlaceholder = "\x00BR\x00"
|
||||
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
|
||||
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
|
||||
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
|
||||
if name == "at" {
|
||||
return tag
|
||||
}
|
||||
return ""
|
||||
})
|
||||
cell = html.UnescapeString(cell)
|
||||
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
|
||||
cell = strings.ReplaceAll(cell, " \n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "\n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "|", `\|`)
|
||||
lines := strings.Fields(cell)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
|
||||
out.WriteString("| ")
|
||||
out.WriteString(strings.Join(row, " | "))
|
||||
out.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func padIMMarkdownTableRow(row []string, cols int) []string {
|
||||
if len(row) >= cols {
|
||||
return row
|
||||
}
|
||||
padded := make([]string, cols)
|
||||
copy(padded, row)
|
||||
return padded
|
||||
}
|
||||
|
||||
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset, index := 0, 1; offset < len(inner); {
|
||||
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := inner[openStart:openEnd]
|
||||
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
|
||||
if body != "" {
|
||||
prefix := "-"
|
||||
if ordered {
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%d.", index)
|
||||
}
|
||||
index++
|
||||
}
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(" ")
|
||||
out.WriteString(indentIMMarkdownListContinuation(body))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
offset = closeEnd
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func indentIMMarkdownListContinuation(body string) string {
|
||||
return strings.ReplaceAll(body, "\n", "\n ")
|
||||
}
|
||||
|
||||
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
|
||||
match := imMarkdownLinkRE.FindStringSubmatch(inner)
|
||||
if match == nil {
|
||||
return "", "", false
|
||||
}
|
||||
href := match[1]
|
||||
if href == "" {
|
||||
href = match[2]
|
||||
}
|
||||
text := strings.TrimSpace(markdownPlainText(match[3]))
|
||||
if text == "" {
|
||||
text = href
|
||||
}
|
||||
return text, html.UnescapeString(href), true
|
||||
}
|
||||
|
||||
func markdownPlainText(s string) string {
|
||||
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
|
||||
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func markdownLinkLabelText(s string) string {
|
||||
text := markdownPlainText(s)
|
||||
if !strings.Contains(text, "---") {
|
||||
return text
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func markdownLink(text, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `[`, `\[`)
|
||||
text = strings.ReplaceAll(text, `]`, `\]`)
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkDestination(href string) string {
|
||||
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
|
||||
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
|
||||
// characters that can terminate or split the Markdown link destination.
|
||||
var out strings.Builder
|
||||
out.Grow(len(href))
|
||||
for i := 0; i < len(href); {
|
||||
if href[i] == '%' {
|
||||
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
|
||||
out.WriteString(href[i : i+3])
|
||||
i += 3
|
||||
} else {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if href[i] < utf8.RuneSelf {
|
||||
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
} else {
|
||||
out.WriteByte(href[i])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(href[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for _, b := range []byte(href[i : i+size]) {
|
||||
writePercentEncodedByte(&out, b)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
|
||||
if b <= ' ' || b >= 0x7f {
|
||||
return true
|
||||
}
|
||||
switch b {
|
||||
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writePercentEncodedByte(out *strings.Builder, b byte) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hex[b>>4])
|
||||
out.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
|
||||
}
|
||||
|
||||
func imMarkdownInlineCode(s string) string {
|
||||
maxRun := 0
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r == '`' {
|
||||
run++
|
||||
if run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
fence := strings.Repeat("`", maxRun+1)
|
||||
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
|
||||
return fence + " " + s + " " + fence
|
||||
}
|
||||
return fence + s + fence
|
||||
}
|
||||
|
||||
func imMarkdownFencedCode(code, lang string) string {
|
||||
maxRun := 0
|
||||
for _, line := range strings.Split(code, "\n") {
|
||||
if run := leadingBacktickRun(line); run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
}
|
||||
fenceLen := maxRun + 1
|
||||
if fenceLen < 3 {
|
||||
fenceLen = 3
|
||||
}
|
||||
fence := strings.Repeat("`", fenceLen)
|
||||
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
|
||||
}
|
||||
|
||||
func leadingBacktickRun(s string) int {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r != '`' {
|
||||
break
|
||||
}
|
||||
run++
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
@@ -72,6 +72,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 isIMMarkdownFetch(runtime) {
|
||||
applyFetchIMMarkdown(data, runtime.Str("doc"))
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -85,7 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
@@ -122,6 +125,14 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
if format == "im-markdown" {
|
||||
return "markdown"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
|
||||
@@ -6,9 +6,12 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -104,6 +107,369 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "revision-id", "42")
|
||||
mustSetFetchFlag(t, runtime, "detail", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["revision_id"]; got != 42 {
|
||||
t.Fatalf("revision_id = %#v, want 42", got)
|
||||
}
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "detail", "with-ids")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "scope", "section")
|
||||
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full omits read option",
|
||||
setFlags: map[string]string{
|
||||
"scope": "full",
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "outline with max depth",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
"max-depth": "3",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "outline",
|
||||
"max_depth": "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with block ids and context",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"end-block-id": "blk_end",
|
||||
"context-before": "2",
|
||||
"context-after": "1",
|
||||
"max-depth": "0",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "blk_start",
|
||||
"end_block_id": "blk_end",
|
||||
"context_before": "2",
|
||||
"context_after": "1",
|
||||
"max_depth": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with query",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context-before": "1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context_before": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section keeps unlimited depth omitted",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
"max-depth": "-1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "negative context before",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-before": "-1",
|
||||
},
|
||||
wantParam: "--context-before",
|
||||
},
|
||||
{
|
||||
name: "negative context after",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-after": "-1",
|
||||
},
|
||||
wantParam: "--context-after",
|
||||
},
|
||||
{
|
||||
name: "max depth below unlimited sentinel",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"max-depth": "-2",
|
||||
},
|
||||
wantParam: "--max-depth",
|
||||
},
|
||||
{
|
||||
name: "range needs boundary",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
},
|
||||
wantParams: []string{
|
||||
"--start-block-id",
|
||||
"--end-block-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword needs keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "section needs start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
err := validateReadModeFlags(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateReadModeFlags() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "outline",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section with start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
t.Fatalf("validateReadModeFlags() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid doc",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.com/sheets/sht_token",
|
||||
},
|
||||
wantParam: "--doc",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateFetchV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateFetchV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "xml format",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "xml",
|
||||
"detail": "full",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markdown simple detail",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": "simple",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
|
||||
t.Fatalf("warning = %q, want empty", got)
|
||||
}
|
||||
if _, ok := data["warnings"]; ok {
|
||||
t.Fatalf("unexpected warnings: %#v", data["warnings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -141,36 +507,54 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "im-markdown",
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
|
||||
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, format := range []string{"markdown", "im-markdown"} {
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(format+"/"+detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": format,
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +645,107 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999999,
|
||||
"msg": "fetch failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchAPIError",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mountAndRunDocs() succeeded, want API error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 999999 {
|
||||
t.Errorf("code = %d, want 999999", p.Code)
|
||||
}
|
||||
if p.Message != "fetch failed" {
|
||||
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdown",
|
||||
"revision_id": float64(1),
|
||||
"content": strings.Join([]string{
|
||||
`<title>Doc Title</title>`,
|
||||
`<callout emoji="💡">Read **this**.</callout>`,
|
||||
`<bookmark name="Example" href="https://example.com"></bookmark>`,
|
||||
}, "\n\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdown",
|
||||
"--doc-format", "im-markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
for _, want := range []string{
|
||||
"# Doc Title",
|
||||
"---\n💡 Read **this**.\n---",
|
||||
"[Example](https://example.com)",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("converted content missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
|
||||
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -291,6 +776,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
@@ -316,6 +802,14 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
|
||||
@@ -91,7 +91,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
}
|
||||
```
|
||||
|
||||
`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
## 参数
|
||||
|
||||
@@ -99,7 +99,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
|------|------|------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
|
||||
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
|
||||
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
|
||||
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Markdown 格式参考
|
||||
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ Card messages (`interactive` type) are not yet supported for compact conversion
|
||||
|
||||
`--audio` sends a voice message and supports only Opus audio files, for example `.opus` files or Ogg Opus (`.ogg`) files. For `mp3`, `wav`, or other non-Opus audio, either convert to `.opus` first and keep using `--audio`, or send the original file as an attachment with `--file`.
|
||||
|
||||
### Sending Doc Content as a Message
|
||||
|
||||
When sending content fetched from a Lark doc as a message, fetch the doc with --doc-format im-markdown, then send it as a message using the --markdown format. The fetched content is already in markdown; in any content-forwarding scenario, keep the fetched original text and send it in the --markdown format. Note: if the doc contains a cite tag with type="user", keep it as-is and do not strip the tag.
|
||||
|
||||
### Flag Types
|
||||
|
||||
Flags support two layers:
|
||||
|
||||
Reference in New Issue
Block a user