mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
Compare commits
2 Commits
feat/get-r
...
codex/lark
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0d5e7bd46 | ||
|
|
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()
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容
|
||||
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
## 操作入口 — 执行操作前必读
|
||||
|
||||
**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)
|
||||
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
|
||||
**CRITICAL — 先根据操作大类查 [`lark-doc-operation-guide.md`](references/lark-doc-operation-guide.md),再读取该操作对应的必读 reference。**
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
操作 guide 把“操作大类 → 必读 reference → 条件加读 → 易混边界”集中维护,避免只凭记忆选择参数或遗漏格式规则。
|
||||
|
||||
**所有操作通用前置:** MUST 先读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),了解认证、权限处理、全局参数、安全规则和路径限制。
|
||||
|
||||
**未读完 guide 中对应操作的必读文件就执行操作会导致参数选择错误或格式错误。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
@@ -60,21 +60,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| 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) |
|
||||
| [`+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) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.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 写入格式。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
|
||||
39
skills/lark-doc/references/lark-doc-operation-guide.md
Normal file
39
skills/lark-doc/references/lark-doc-operation-guide.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# lark-doc 操作入口 Guide
|
||||
|
||||
本文件维护执行前的入口判断:先用“操作大类前置”确定必读 reference,再用“易混边界”避免跨 skill 或资源类型选错。具体参数、示例和工作流仍以各 reference 为准。
|
||||
|
||||
所有操作都默认先读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),了解认证、权限、安全规则、全局参数和路径限制。
|
||||
|
||||
## 操作大类前置
|
||||
|
||||
| 操作大类 | 触发场景 | 必读 reference | 条件加读 |
|
||||
|-|-|-|-|
|
||||
| 读取文档 | 浏览、总结、摘取正文、定位 block、获取直达链接、提取素材或嵌入对象 token | [`lark-doc-fetch.md`](lark-doc-fetch.md) | 需要 Markdown 输出或基于 Markdown 更新时读 [`lark-doc-md.md`](lark-doc-md.md) |
|
||||
| 创建文档 | 新建 Docx/Wiki 文档,含短文档、长文档骨架、Markdown 导入 | [`lark-doc-create.md`](lark-doc-create.md), [`lark-doc-xml.md`](lark-doc-xml.md) | 用户提供 `.md` 或明确要求 Markdown 时读 [`lark-doc-md.md`](lark-doc-md.md);长文档读 [`style/lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md);需要根据题材组织文档时读 [`style/topics/topic-router.md`](style/topics/topic-router.md);需要富 block 或美化时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
|
||||
| 编辑文档 | 替换、插入、删除、移动、复制、追加、覆盖、改写、润色、重排版 | [`lark-doc-update.md`](lark-doc-update.md), [`lark-doc-xml.md`](lark-doc-xml.md) | 用户明确要求 Markdown 或需 Markdown 跨行匹配时读 [`lark-doc-md.md`](lark-doc-md.md);改写/润色读 [`style/lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md);需要富 block 或美化时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
|
||||
| 正文素材 | 插入、预览或下载正文图片/附件,下载画板缩略图 | 对应操作的 [`lark-doc-media-insert.md`](lark-doc-media-insert.md) / [`lark-doc-media-preview.md`](lark-doc-media-preview.md) / [`lark-doc-media-download.md`](lark-doc-media-download.md) | 需要从文档中提取素材 token 时先读 [`lark-doc-fetch.md`](lark-doc-fetch.md) |
|
||||
| 文档级资源 | 下载、更新或删除 Docx 封面图 | [`lark-doc-resource-cover.md`](lark-doc-resource-cover.md) | 无;封面不是正文 `<img>`,不要走 `+media-*` |
|
||||
| 画板协作 | 新增 Mermaid/SVG 画板,或更新已有画板 | [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) | 插入新的 `<whiteboard>` block 时读 [`lark-doc-xml.md`](lark-doc-xml.md);更新已有复杂画板时读 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md);需要美化/结构化表达时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
|
||||
| 嵌入对象下钻 | 正文中出现 `<sheet>`、`<bitable>`、`<cite file-type=...>`、`<vc-transcribe-tab>`、`<synced_reference>` 等 | [`lark-doc-fetch.md`](lark-doc-fetch.md) | 按对象类型切到 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)、[`../../lark-base/SKILL.md`](../../lark-base/SKILL.md)、[`../../lark-note/SKILL.md`](../../lark-note/SKILL.md) 或继续用 `docs +fetch` 读取源文档 |
|
||||
| 非本 skill | 评论、评论回复、reaction、权限、云空间文件管理、导入导出 | 对应目标 skill | 评论/云空间管理走 [`../../lark-drive/SKILL.md`](../../lark-drive/SKILL.md);表格/Base 内部数据走 sheets/base |
|
||||
|
||||
## 易混边界
|
||||
|
||||
- 正文图片、附件和画板缩略图走正文素材操作;文档封面走 [`lark-doc-resource-cover.md`](lark-doc-resource-cover.md),不要把封面当正文 `<img>` 处理。
|
||||
- 已有复杂画板的查询、导出、渲染验证和写入以 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 的流程为准。
|
||||
- 评论、权限、云空间文件管理、导入导出不归本 skill,按场景切到 [`../../lark-drive/SKILL.md`](../../lark-drive/SKILL.md)。
|
||||
- 文档内嵌 `<sheet>` / `<bitable>` / `<cite file-type=...>` 时,本 skill 只负责提取 token;对象内部数据读取和修改切到对应 skill。
|
||||
|
||||
## 格式选择
|
||||
|
||||
- **创建 / 导入场景**:XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说“导入 Markdown”时直接用 Markdown;否则默认 XML。
|
||||
- **精准编辑场景**:`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修优先 XML。
|
||||
- **Markdown 限制**:Markdown 不携带 block ID,也无样式。需要按 block ID 定位时,先用 `docs +fetch --detail with-ids` 局部获取目标段落。
|
||||
- **富 block**:callout、grid、table、whiteboard 等结构化表达由内容和用户意图决定;不要为了“丰富”强行套用固定结构。
|
||||
|
||||
## 校验要点
|
||||
|
||||
- 写操作后,如继续 block 级操作,按 [`lark-doc-update.md`](lark-doc-update.md) 的“Block ID 生命周期”判断是否需要重新 fetch。
|
||||
- `overwrite` / `block_replace` / `block_delete` 后不要复用受影响旧 ID。
|
||||
- 插入 / 复制新块后,要操作新块必须重新 fetch 获取新 block ID。
|
||||
- 正文素材走 `+media-*`;文档封面走 `+resource-* --type cover`。
|
||||
@@ -19,30 +19,31 @@
|
||||
### 步骤一:规划与初始创建(串行)
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
2. 如需从零组织文档且用户未指定固定模板,先读 [`topics/topic-router.md`](topics/topic-router.md) 判断题材;命中题材后加读对应题材指引,再设计大纲
|
||||
3. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
4. `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 需收到:
|
||||
5. 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 路径和用于画图的源内容
|
||||
6. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
7. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
8. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 步骤四:画板处理与润色(并行 Agent)
|
||||
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
9. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
10. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
20
skills/lark-doc/references/style/topics/meeting-notes.md
Normal file
20
skills/lark-doc/references/style/topics/meeting-notes.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 会议纪要文档指引
|
||||
|
||||
## 适用场景
|
||||
|
||||
会议纪要、同步会记录、讨论结论、评审记录等需要沉淀共识和行动项的文档。
|
||||
|
||||
## 结构建议
|
||||
|
||||
- 会议信息:主题、时间、参会人
|
||||
- 背景或议题
|
||||
- 讨论要点
|
||||
- 已达成结论
|
||||
- 待办事项与负责人
|
||||
- 未决问题
|
||||
|
||||
## 表达建议
|
||||
|
||||
- 先写结论和行动项,再保留必要讨论过程。
|
||||
- 待办事项尽量写清负责人、截止时间和验收标准。
|
||||
- 已知 open_id 的人员使用 `<cite type="user" user-id="..."></cite>`。
|
||||
20
skills/lark-doc/references/style/topics/project-plan.md
Normal file
20
skills/lark-doc/references/style/topics/project-plan.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 项目计划文档指引
|
||||
|
||||
## 适用场景
|
||||
|
||||
项目计划、推进方案、排期、里程碑、跨团队协作方案等需要说明目标、范围、路径和风险的文档。
|
||||
|
||||
## 结构建议
|
||||
|
||||
- 背景与目标
|
||||
- 范围与不做事项
|
||||
- 里程碑与时间安排
|
||||
- 工作拆解与责任分工
|
||||
- 风险、依赖与应对
|
||||
- 下一步动作
|
||||
|
||||
## 表达建议
|
||||
|
||||
- 时间线、依赖关系和关键路径适合用表格或画板。
|
||||
- 不要虚构日期、负责人或指标;缺失信息标为待确认。
|
||||
- 风险和依赖要写出影响与应对,不只列名称。
|
||||
19
skills/lark-doc/references/style/topics/report.md
Normal file
19
skills/lark-doc/references/style/topics/report.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 汇报总结文档指引
|
||||
|
||||
## 适用场景
|
||||
|
||||
周报、月报、阶段总结、复盘、述职、项目汇报等需要向读者说明进展、结果、问题和下一步的文档。
|
||||
|
||||
## 结构建议
|
||||
|
||||
- 结论或总体状态
|
||||
- 关键进展与成果
|
||||
- 数据、事实或案例支撑
|
||||
- 问题、风险与原因
|
||||
- 下一步计划
|
||||
|
||||
## 表达建议
|
||||
|
||||
- 面向管理层时先给结论,再展开细节。
|
||||
- 有指标变化时说明口径,不要只堆数字。
|
||||
- 风险、阻塞和待决策事项可以用列表或 callout,但不要过度使用。
|
||||
16
skills/lark-doc/references/style/topics/topic-router.md
Normal file
16
skills/lark-doc/references/style/topics/topic-router.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 文档题材路由
|
||||
|
||||
创建文档前,如果用户没有给定固定模板,先根据用户意图识别题材,再加读对应题材指引。题材指引用于帮助选择结构、语气和信息组织方式,不是强制模板。
|
||||
|
||||
| 用户意图 / 信号 | 题材 | 加读指引 |
|
||||
|-|-|-|
|
||||
| 周报、月报、复盘、总结、汇报、述职 | 汇报总结 | [`report.md`](report.md) |
|
||||
| 会议纪要、会议总结、讨论记录、同步会 | 会议纪要 | [`meeting-notes.md`](meeting-notes.md) |
|
||||
| 项目计划、排期、里程碑、推进方案 | 项目计划 | [`project-plan.md`](project-plan.md) |
|
||||
|
||||
## 规则
|
||||
|
||||
- 用户明确指定题材、结构或模板时,优先用户指定。
|
||||
- 命中多个题材时,只选择主目标对应的 1 个题材,不组合多个指引。
|
||||
- 题材指引只提供结构和表达建议,不覆盖用户给出的事实、格式和语气要求。
|
||||
- 没有明显题材时,不强行套题材,按通用创建工作流执行。
|
||||
@@ -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