mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
2 Commits
feat/get-r
...
sun/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba8ac1c83 | ||
|
|
1c92ed8841 |
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()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -26,7 +25,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md)
|
||||
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
@@ -67,7 +66,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) |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
>
|
||||
@@ -74,12 +73,11 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
|
||||
- **较长文档**:先建骨架再通过 `docs +update` 分段写入;短文档可一次写完整内容。
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
|
||||
@@ -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 写入格式。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
>
|
||||
@@ -253,7 +252,6 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 从零创作工作流
|
||||
|
||||
用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
|
||||
|
||||
## 核心方法论 — Code-Act Loop
|
||||
|
||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
|
||||
|
||||
|
||||
## 典型 Code-Act Loop 流程
|
||||
|
||||
### 步骤一:规划与初始创建(串行)
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
||||
|
||||
### 步骤二:分段撰写(并行 Agent)
|
||||
|
||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
||||
- `lark-doc-xml.md` 的完整路径(Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md`
|
||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
||||
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 步骤四:画板处理与润色(并行 Agent)
|
||||
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 改写增强工作流
|
||||
|
||||
用户提供已有文档链接或 token,需要改写、润色、补充或重排版时,遵循本工作流。
|
||||
|
||||
## 核心方法论 — Code-Act Loop
|
||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
## 核心原则:精准手术优于全量覆盖
|
||||
1. **精准手术**:只改用户指定的 block,不改其他 block。
|
||||
2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
|
||||
3. **保真约束**:改写时原文里的 `<cite type="user">`(@人)、`<cite type="doc">`(@文档)、`<img>`、`<source>`、`<whiteboard>`、`<sheet>`、`<bitable>`、`<synced_reference>` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 步骤一:分析与画板识别(串行)
|
||||
|
||||
1. **选择读取范围**(节省上下文的关键):
|
||||
- 用户只改某一节 / 文档较大 → 先 `docs +fetch --api-version v2 --scope outline --max-depth 2` 拿目录,再 `docs +fetch --api-version v2 --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
|
||||
- 需要精确跨节区间 → `docs +fetch --api-version v2 --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --api-version v2 --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 步骤二:定向改写(并行 Agent)
|
||||
|
||||
5. **优先处理步骤一识别出的画板候选段落**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
@@ -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