From e8df0ea63e8eb1742267adae5fbc17cc08d93c0b Mon Sep 17 00:00:00 2001 From: caojie0621 Date: Fri, 17 Apr 2026 20:05:08 +0800 Subject: [PATCH] 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 ! 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. --- shortcuts/drive/drive_add_comment.go | 267 ++++++-- shortcuts/drive/drive_add_comment_test.go | 569 +++++++++++++++++- .../references/lark-drive-add-comment.md | 34 +- span.log | 0 4 files changed, 799 insertions(+), 71 deletions(-) create mode 100644 span.log diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index cd72a7406..ccd21b760 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -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: ! (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: !, 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 ! 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: "", 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 = "" + } 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 "", "", "wiki" - } - return "", "docx", "wiki" - default: - return "", "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 !", 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 "" } -func dryRunLocateDocRef(docRef commentDocRef) string { - if docRef.Kind == "wiki" { - return "" - } - 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 "!" (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 ! (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: !, 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 { diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index ae8b02fde..e2ea2bb41 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -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(), "!") { + 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 != "" { + t.Fatalf("expected placeholder, got %s", got) + } + if got := anchorBlockIDForDryRun(" "); got != "" { + 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") + } +} diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 95f00cbf2..411df5674 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -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 格式为 !) +lark-cli drive +add-comment \ + --doc "https://example.larksuite.com/sheets/" \ + --block-id "!D6" \ + --content '[{"type":"text","text":"请检查此单元格数据"}]' + +# wiki 链接指向的 sheet 也支持 +lark-cli drive +add-comment \ + --doc "https://example.larksuite.com/wiki/" \ + --block-id "!A1" \ + --content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 确认"}]' + +# 传裸 token 时需要 --type 指定文档类型 +lark-cli drive +add-comment \ + --doc "" --type sheet \ + --block-id "!D6" \ + --content '[{"type":"text","text":"请检查"}]' + +lark-cli drive +add-comment \ + --doc "" --type docx \ + --content '[{"type":"text","text":"全文评论"}]' + # 已知 block_id 时可跳过 MCP 直接创建局部评论 lark-cli drive +add-comment \ --doc "" \ @@ -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 评论**:格式为 `!`(如 `a281f9!D6`) | ## 行为说明 +- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "!"` 指定单元格(如 `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。 diff --git a/span.log b/span.log new file mode 100644 index 000000000..e69de29bb