mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
* feat(doc): expand callout type= shorthand into background-color and border-color When users write <callout type="warning" emoji="📝"> without an explicit background-color, the Feishu doc renders the block with no color. This commit adds fixCalloutType() which maps the semantic type= attribute to the corresponding background-color/border-color pair accepted by create-doc. - warning → light-yellow/yellow - info/note → light-blue/blue - tip/success/check → light-green/green - error/danger → light-red/red - caution → light-orange/orange - important → light-purple/purple Explicit background-color or border-color attributes are always preserved. The fix is applied via prepareMarkdownForCreate() in both +create and +update paths, and also inside fixExportedMarkdown() for round-trip fidelity. * refactor(doc): replace silent callout type→color injection with hint output Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is the wrong layer for this adaptation. The type→color mapping is not part of the Feishu spec, and covert transforms make debugging harder. Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType() which leaves the Markdown unchanged and instead writes a hint line to stderr for each callout tag that has type= but no background-color, telling the user the recommended explicit attributes to add: hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow" Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted and double-quoted attribute values (type='warning' and type="warning"). * fix(doc): harden background-color detection in WarnCalloutType CodeRabbit flagged that the previous strings.Contains(attrs, "background-color=") check missed forms like 'background-color = "light-red"' with whitespace around the equals sign. Replace with a regex that tolerates optional whitespace, and add a regression test. * fix(doc): close real review gaps left over after rebase PR #467's review thread had three substantive comments (`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed were fixed in commit 7d4b556 — but that commit no longer exists on the branch (lost in a rebase / squash), and the head still ships the original buggy code. This commit makes the fixes real. Three behavior fixes in shortcuts/doc/markdown_fix.go: 1. (#5) Tighten the type= and background-color= regex anchors. \b sits at any word/non-word boundary, and `-` is a non-word char, so `\btype=` also matched the suffix of `data-type=` — a tag like `<callout data-type="warning">` would emit a bogus light-yellow hint. Switched both regexes to `(?:^|\s)…` so a real attribute separator is required. The same anchor on background-color closes the symmetric case where a `data-background-color=` attribute would silently suppress the real hint. 2. (#4) WarnCalloutType is now a fence-aware line walker. Previously the regex ran over the entire markdown body, so a callout sample inside a documentation code fence (```markdown … ```) would generate a phantom stderr hint every time the docs mentioned the feature. The walker tracks fence state via the existing codeFenceOpenMarker / isCodeFenceClose helpers from docs_update_check.go, which handle both backtick and tilde fences per CommonMark §4.5. 3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The previous code routed callout iteration through a rewrite primitive whose rebuilt-string return value was discarded, then ran the same regex a second time inside the callback to recover the capture groups. New scanCalloutTagsForWarning helper uses FindAllStringSubmatch — one pass, no thrown-away allocation, intent matches the surface (read-only scan, not a mutator). Tests: 5 new TestWarnCalloutType subtests pin each contract: - data-type attribute does not trigger hint (#5) - data-background-color does not suppress hint (#5, symmetric) - callout inside backtick fence emits no hint (#4) - callout inside tilde fence emits no hint (#4) - callout after fence close still emits hint (#4, fence-state reset) All 14 TestWarnCalloutType cases pass; go vet / golangci-lint --new-from-rev=origin/main both clean.
273 lines
8.0 KiB
Go
273 lines
8.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
|
|
func v1CreateFlags() []common.Flag {
|
|
return []common.Flag{
|
|
{Name: "title", Desc: "document title", Hidden: true},
|
|
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
|
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
|
|
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
|
|
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
|
|
}
|
|
}
|
|
|
|
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
|
|
|
|
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
|
|
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
|
func useV2Create(runtime *common.RuntimeContext) bool {
|
|
if runtime.Str("api-version") == "v2" {
|
|
return true
|
|
}
|
|
return runtime.Str("content") != "" ||
|
|
runtime.Str("parent-token") != "" ||
|
|
runtime.Str("parent-position") != ""
|
|
}
|
|
|
|
var DocsCreate = common.Shortcut{
|
|
Service: "docs",
|
|
Command: "+create",
|
|
Description: "Create a Lark document",
|
|
Risk: "write",
|
|
AuthTypes: []string{"user", "bot"},
|
|
Scopes: []string{"docx:document:create"},
|
|
Tips: docsVersionSelectionTips,
|
|
Flags: concatFlags(
|
|
[]common.Flag{
|
|
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
|
},
|
|
v1CreateFlags(),
|
|
v2CreateFlags(),
|
|
),
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if useV2Create(runtime) {
|
|
return validateCreateV2(ctx, runtime)
|
|
}
|
|
return validateCreateV1(ctx, runtime)
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
if useV2Create(runtime) {
|
|
return dryRunCreateV2(ctx, runtime)
|
|
}
|
|
return dryRunCreateV1(ctx, runtime)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if useV2Create(runtime) {
|
|
return executeCreateV2(ctx, runtime)
|
|
}
|
|
return executeCreateV1(ctx, runtime)
|
|
},
|
|
PostMount: func(cmd *cobra.Command) {
|
|
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
|
},
|
|
}
|
|
|
|
// ── V1 (MCP) implementation ──
|
|
|
|
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
if runtime.Str("markdown") == "" {
|
|
return common.FlagErrorf("--markdown is required")
|
|
}
|
|
count := 0
|
|
if runtime.Str("folder-token") != "" {
|
|
count++
|
|
}
|
|
if runtime.Str("wiki-node") != "" {
|
|
count++
|
|
}
|
|
if runtime.Str("wiki-space") != "" {
|
|
count++
|
|
}
|
|
if count > 1 {
|
|
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
args := buildCreateArgsV1(runtime)
|
|
d := common.NewDryRunAPI().
|
|
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
|
Desc("MCP tool: create-doc").
|
|
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
|
Set("mcp_tool", "create-doc").Set("args", args)
|
|
if runtime.IsBot() {
|
|
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
|
}
|
|
return d
|
|
}
|
|
|
|
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
warnDeprecatedV1(runtime, "+create")
|
|
// Surface callout type= hint so users know to switch to background-color/
|
|
// border-color when they want a colored callout. Non-blocking, advisory.
|
|
if md := runtime.Str("markdown"); md != "" {
|
|
WarnCalloutType(md, runtime.IO().ErrOut)
|
|
}
|
|
args := buildCreateArgsV1(runtime)
|
|
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
augmentCreateResultV1(runtime, result)
|
|
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
|
runtime.Out(result, nil)
|
|
return nil
|
|
}
|
|
|
|
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
|
md := runtime.Str("markdown")
|
|
args := map[string]interface{}{
|
|
"markdown": md,
|
|
}
|
|
if v := runtime.Str("title"); v != "" {
|
|
args["title"] = v
|
|
}
|
|
if v := runtime.Str("folder-token"); v != "" {
|
|
args["folder_token"] = v
|
|
}
|
|
if v := runtime.Str("wiki-node"); v != "" {
|
|
args["wiki_node"] = v
|
|
}
|
|
if v := runtime.Str("wiki-space"); v != "" {
|
|
args["wiki_space"] = v
|
|
}
|
|
return args
|
|
}
|
|
|
|
type docsPermissionTarget struct {
|
|
Token string
|
|
Type string
|
|
}
|
|
|
|
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
|
target := selectPermissionTarget(result)
|
|
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
|
result["permission_grant"] = grant
|
|
}
|
|
fallbackDocURLV1(runtime, result)
|
|
}
|
|
|
|
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
|
|
// response did not include one but did include a doc_id. This protects against
|
|
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
|
|
// drops structured fields.
|
|
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
|
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
|
|
return
|
|
}
|
|
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
|
if docID == "" {
|
|
return
|
|
}
|
|
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
|
|
result["doc_url"] = u
|
|
}
|
|
}
|
|
|
|
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
|
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
|
return ref
|
|
}
|
|
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
|
if docID != "" {
|
|
return docsPermissionTarget{Token: docID, Type: "docx"}
|
|
}
|
|
return docsPermissionTarget{}
|
|
}
|
|
|
|
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
|
if strings.TrimSpace(docURL) == "" {
|
|
return docsPermissionTarget{}, false
|
|
}
|
|
ref, err := parseDocumentRef(docURL)
|
|
if err != nil {
|
|
return docsPermissionTarget{}, false
|
|
}
|
|
switch ref.Kind {
|
|
case "wiki":
|
|
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
|
|
case "doc", "docx":
|
|
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
|
|
default:
|
|
return docsPermissionTarget{}, false
|
|
}
|
|
}
|
|
|
|
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
|
|
// whiteboard creation markdown is detected.
|
|
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
|
|
if !isWhiteboardCreateMarkdown(markdown) {
|
|
return
|
|
}
|
|
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
|
}
|
|
|
|
func isWhiteboardCreateMarkdown(markdown string) bool {
|
|
lower := strings.ToLower(markdown)
|
|
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
|
return true
|
|
}
|
|
return strings.Contains(lower, "<whiteboard") &&
|
|
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
|
}
|
|
|
|
func normalizeBoardTokens(raw interface{}) []string {
|
|
switch v := raw.(type) {
|
|
case nil:
|
|
return []string{}
|
|
case []string:
|
|
return v
|
|
case []interface{}:
|
|
tokens := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
tokens = append(tokens, s)
|
|
}
|
|
}
|
|
return tokens
|
|
case string:
|
|
if v == "" {
|
|
return []string{}
|
|
}
|
|
return []string{v}
|
|
default:
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
// ── Shared helpers ──
|
|
|
|
// concatFlags combines multiple flag slices into one.
|
|
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
|
var out []common.Flag
|
|
for _, s := range slices {
|
|
out = append(out, s...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
|
|
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
|
|
m := make(map[string]string, len(v1)+len(v2))
|
|
for _, f := range v1 {
|
|
m[f.Name] = "v1"
|
|
}
|
|
for _, f := range v2 {
|
|
m[f.Name] = "v2"
|
|
}
|
|
return m
|
|
}
|