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:
liujiashu-shiro
2026-06-25 11:49:58 +08:00
committed by GitHub
parent 644c3c77dd
commit 1c92ed8841
8 changed files with 2706 additions and 31 deletions

View 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("![%s](%s)", 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 ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"))

View File

@@ -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()

View File

@@ -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) |

View File

@@ -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`(省略 = 读整篇) |

View File

@@ -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 写入格式
## 创建文档标题

View File

@@ -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: