mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
feat(drive): support sheet cell comments in +add-comment (#518)
Extend +add-comment to accept sheet URLs and wiki URLs that resolve to sheets. Reuse --block-id with <sheetId>!<cell> format (e.g. a281f9!D6) for sheet cell positioning. Wiki links resolving to sheet type are handled by first calling get_node, then redirecting to the sheet comment path with proper parameter validation.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
|
||||
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docx:document:readonly",
|
||||
@@ -72,14 +73,15 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
|
||||
{Name: "doc", Desc: "document URL/token, sheet URL, or wiki URL that resolves to doc/docx/sheet", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +90,21 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -99,37 +116,69 @@ var DriveAddComment = common.Shortcut{
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
|
||||
return output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
|
||||
// For wiki URLs, resolve the actual target type via API so dry-run
|
||||
// matches real execution behavior instead of guessing from --block-id.
|
||||
resolvedKind := docRef.Kind
|
||||
resolvedToken := docRef.Token
|
||||
isWiki := false
|
||||
if docRef.Kind == "wiki" {
|
||||
isWiki = true
|
||||
target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), commentModeFull)
|
||||
if err == nil {
|
||||
resolvedKind = target.FileType
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
if anchor == nil {
|
||||
anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0}
|
||||
}
|
||||
commentBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
desc := "1-step request: create sheet comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create sheet comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
|
||||
targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)
|
||||
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
|
||||
commentBody := buildCommentCreateV2Request(resolvedKind, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
|
||||
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
|
||||
}
|
||||
|
||||
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
switch {
|
||||
case mode == commentModeFull && resolvedBy == "wiki":
|
||||
case mode == commentModeFull && isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
|
||||
case mode == commentModeFull:
|
||||
dry.Desc("1-step request: create full comment")
|
||||
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
|
||||
case isWiki && strings.TrimSpace(selection) != "":
|
||||
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
|
||||
case resolvedBy == "wiki":
|
||||
case isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
|
||||
case strings.TrimSpace(selection) != "":
|
||||
dry.Desc("2-step orchestration: locate block -> create local comment")
|
||||
@@ -137,19 +186,17 @@ var DriveAddComment = common.Shortcut{
|
||||
dry.Desc("1-step request: create local comment with explicit block ID")
|
||||
}
|
||||
|
||||
if resolvedBy == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to target document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
|
||||
if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
|
||||
step := "[1]"
|
||||
if resolvedBy == "wiki" {
|
||||
if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
docID := resolvedToken
|
||||
if isWiki && resolvedToken == docRef.Token {
|
||||
docID = "<resolved_docx_token>"
|
||||
}
|
||||
mcpArgs := map[string]interface{}{
|
||||
"doc_id": dryRunLocateDocRef(docRef),
|
||||
"doc_id": docID,
|
||||
"limit": defaultLocateDocLimit,
|
||||
"selection_with_ellipsis": selection,
|
||||
}
|
||||
@@ -171,23 +218,29 @@ var DriveAddComment = common.Shortcut{
|
||||
if mode == commentModeLocal {
|
||||
createDesc = "Create local comment"
|
||||
step = "[2]"
|
||||
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
|
||||
if isWiki && strings.TrimSpace(selection) != "" {
|
||||
step = "[3]"
|
||||
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
|
||||
} else if isWiki || strings.TrimSpace(selection) != "" {
|
||||
step = "[2]"
|
||||
} else {
|
||||
step = "[1]"
|
||||
}
|
||||
} else if resolvedBy == "wiki" {
|
||||
} else if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
|
||||
return dry.POST(createPath).
|
||||
Desc(step+" "+createDesc).
|
||||
Body(commentBody).
|
||||
Set("file_token", targetToken)
|
||||
Set("file_token", resolvedToken)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
@@ -197,6 +250,11 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Wiki resolved to sheet: redirect to sheet comment path.
|
||||
if target.FileType == "sheet" {
|
||||
return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken})
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,9 +283,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
|
||||
}
|
||||
|
||||
if mode == commentModeLocal {
|
||||
@@ -288,7 +346,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
|
||||
return commentModeLocal
|
||||
}
|
||||
|
||||
func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
@@ -297,6 +355,9 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/wiki/"); ok {
|
||||
return commentDocRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/docx/"); ok {
|
||||
return commentDocRef{Kind: "docx", Token: token}, nil
|
||||
}
|
||||
@@ -304,40 +365,29 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
return commentDocRef{Kind: "docx", Token: raw}, nil
|
||||
}
|
||||
|
||||
func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) {
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, "docx", "docx"
|
||||
case "doc":
|
||||
return docRef.Token, "doc", "doc"
|
||||
case "wiki":
|
||||
if mode == commentModeFull {
|
||||
return "<resolved_file_token>", "<resolved_file_type>", "wiki"
|
||||
}
|
||||
return "<resolved_docx_token>", "docx", "wiki"
|
||||
default:
|
||||
return "<resolved_docx_token>", "docx", "docx"
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) {
|
||||
docRef, err := parseCommentDocRef(input)
|
||||
docRef, err := parseCommentDocRef(input, runtime.Str("type"))
|
||||
if err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -364,11 +414,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "" || objToken == "" {
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "sheet",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -531,12 +592,24 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
return replyElements, nil
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
|
||||
type sheetAnchor struct {
|
||||
SheetID string
|
||||
Col int
|
||||
Row int
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"reply_elements": replyElements,
|
||||
}
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
if sheet != nil {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": sheet.SheetID,
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -551,13 +624,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func dryRunLocateDocRef(docRef commentDocRef) string {
|
||||
if docRef.Kind == "wiki" {
|
||||
return "<resolved_docx_token>"
|
||||
}
|
||||
return docRef.Token
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -576,6 +642,83 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSheetCellRef parses "<sheetId>!<cell>" (e.g. "a281f9!D6") into a sheetAnchor.
|
||||
// Column is converted from letter to 0-based index (A=0), row from 1-based to 0-based.
|
||||
func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
parts := strings.SplitN(input, "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse cell reference like "D6" into col letter + row number.
|
||||
i := 0
|
||||
for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
|
||||
// Column letter to 0-based index: A=0, B=1, ..., Z=25, AA=26.
|
||||
col := 0
|
||||
for _, ch := range colStr {
|
||||
col = col*26 + int(ch-'A'+1)
|
||||
}
|
||||
col-- // convert to 0-based
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(docRef.Token))
|
||||
requestBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
|
||||
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"file_token": docRef.Token,
|
||||
"file_type": "sheet",
|
||||
"comment_mode": "sheet",
|
||||
"block_id": blockID,
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractURLToken(raw, marker string) (string, bool) {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -6,6 +6,9 @@ package drive
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseCommentDocRef(t *testing.T) {
|
||||
@@ -14,6 +17,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
docType string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
@@ -31,11 +35,31 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token treated as docx",
|
||||
name: "raw token with type docx",
|
||||
input: "xxxxxx",
|
||||
docType: "docx",
|
||||
wantKind: "docx",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token with type sheet",
|
||||
input: "shtToken",
|
||||
docType: "sheet",
|
||||
wantKind: "sheet",
|
||||
wantToken: "shtToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type doc",
|
||||
input: "docToken",
|
||||
docType: "doc",
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "old doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -53,7 +77,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentDocRef(tt.input)
|
||||
got, err := parseCommentDocRef(tt.input, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -249,7 +273,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
"text": "全文评论",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -279,7 +303,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
"text": "评论内容",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -300,3 +324,540 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Fatalf("unexpected reply element: %#v", gotReplyElements[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment tests ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseCommentDocRefSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefSheetWithQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "请修正此单元格"},
|
||||
}
|
||||
got := buildCommentCreateV2Request("sheet", "", replyElements, &sheetAnchor{
|
||||
SheetID: "abc123",
|
||||
Col: 3,
|
||||
Row: 5,
|
||||
})
|
||||
|
||||
if got["file_type"] != "sheet" {
|
||||
t.Fatalf("expected file_type sheet, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "abc123" {
|
||||
t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["sheet_col"] != 3 {
|
||||
t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"])
|
||||
}
|
||||
if anchor["sheet_row"] != 5 {
|
||||
t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "test"},
|
||||
}
|
||||
// When both sheet anchor and blockID are provided, sheet anchor wins.
|
||||
got := buildCommentCreateV2Request("sheet", "should_be_ignored", replyElements, &sheetAnchor{
|
||||
SheetID: "s1",
|
||||
Col: 0,
|
||||
Row: 0,
|
||||
})
|
||||
anchor := got["anchor"].(map[string]interface{})
|
||||
if anchor["block_id"] != "s1" {
|
||||
t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"])
|
||||
}
|
||||
if _, exists := anchor["sheet_col"]; !exists {
|
||||
t.Fatal("expected sheet_col in anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
sheetID string
|
||||
col int
|
||||
row int
|
||||
}{
|
||||
{"A1", "s1!A1", "s1", 0, 0},
|
||||
{"D6", "abc!D6", "abc", 3, 5},
|
||||
{"AA1", "s1!AA1", "s1", 26, 0},
|
||||
{"lowercase", "s1!d6", "s1", 3, 5},
|
||||
{"B10", "sheet1!B10", "sheet1", 1, 9},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSheetCellRef(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row {
|
||||
t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef(input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment validate tests ────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "no-exclamation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<sheetId>!<cell>") {
|
||||
t.Fatalf("expected format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--full-comment",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment execute tests ─────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"请检查"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "comment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "c456"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "abc!A1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiResolve(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiSheetComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"wiki sheet comment"}]`,
|
||||
"--block-id", "s1!B3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiSheetComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSheet(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToDocxFull(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxLocalWithBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "local comment") {
|
||||
t.Fatalf("dry-run output missing local comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiDocxComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiDocxComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiIncompleteNodeData(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete node error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/doc/oldDocToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx and sheet") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional unit function tests ──────────────────────────────────────────
|
||||
|
||||
func TestAnchorBlockIDForDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" {
|
||||
t.Fatalf("expected blk_123, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(""); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder for whitespace, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefRowZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef("s1!A0")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be >= 1") {
|
||||
t.Fatalf("expected row validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefPathLikeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCommentDocRef("token/with/slash", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") {
|
||||
t.Fatalf("expected unsupported doc error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty token after marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
给文档添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论),也支持传最终可解析为 doc/docx 的 wiki URL。
|
||||
给文档或电子表格添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、sheet URL,也支持传最终可解析为 doc/docx/sheet 的 wiki URL。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -42,6 +42,28 @@ lark-cli drive +add-comment \
|
||||
--selection-with-ellipsis "流程" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
|
||||
|
||||
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>)
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/sheets/<SHEET_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查此单元格数据"}]'
|
||||
|
||||
# wiki 链接指向的 sheet 也支持
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!A1" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 确认"}]'
|
||||
|
||||
# 传裸 token 时需要 --type 指定文档类型
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<SHEET_TOKEN>" --type sheet \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查"}]'
|
||||
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" --type docx \
|
||||
--content '[{"type":"text","text":"全文评论"}]'
|
||||
|
||||
# 已知 block_id 时可跳过 MCP 直接创建局部评论
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" \
|
||||
@@ -67,14 +89,16 @@ lark-cli drive +add-comment \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL / token,或可解析到 `doc`/`docx` 的 wiki URL。原始 token 默认按 `docx` 处理;旧版 `doc` 请传 URL 或 wiki 链接 |
|
||||
| `--doc` | 是 | 文档 URL / token、sheet URL,或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`sheet`。URL 输入时自动识别,无需传 |
|
||||
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论 |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥 |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥 |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet) |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet) |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6`) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。
|
||||
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis` 时,shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
|
||||
- 未传 `--selection-with-ellipsis` / `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。
|
||||
- 全文评论支持 `docx`、旧版 `doc` URL,以及最终可解析为 `doc`/`docx` 的 wiki URL。
|
||||
|
||||
Reference in New Issue
Block a user