Skip to content

Commit e54220a

Browse files
authored
feat: support files in drive +add-comment (#975)
* feat: support markdown files in drive +add-comment Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf * fix: use markdown file comment anchor placeholder Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e * fix: gate drive file comments by supported extensions Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
1 parent d3fbc88 commit e54220a

7 files changed

Lines changed: 652 additions & 26 deletions

File tree

shortcuts/drive/drive_add_comment.go

Lines changed: 241 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
4747
// with `drive file.comments create_v2` against a fresh docx.
4848
const maxCommentTotalRunes = 10000
4949

50+
// The file comment API treats supported Drive file comments as full-file
51+
// comments in the UI, but currently rejects an empty anchor.block_id for file
52+
// targets. TODO: remove this placeholder after the API accepts omitting
53+
// anchor.block_id for file full comments.
54+
const fileFullCommentAnchorBlockID = "test"
55+
56+
// File comments are enabled only for extensions verified to render correctly in
57+
// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and
58+
// xlsx currently accept the API request but display poorly in the page.
59+
var supportedFileCommentExtensions = []string{
60+
".md",
61+
".txt",
62+
".json",
63+
".csv",
64+
".go",
65+
".js",
66+
".py",
67+
".pptx",
68+
".png",
69+
".jpg",
70+
".jpeg",
71+
".zip",
72+
".mp3",
73+
".mp4",
74+
}
75+
76+
var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions)
77+
5078
type commentDocRef struct {
5179
Kind string
5280
Token string
@@ -93,17 +121,18 @@ const (
93121
var DriveAddComment = common.Shortcut{
94122
Service: "drive",
95123
Command: "+add-comment",
96-
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
124+
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
97125
Risk: "write",
98126
Scopes: []string{
127+
"drive:drive.metadata:readonly",
99128
"docx:document:readonly",
100129
"docs:document.comment:create",
101130
"docs:document.comment:write_only",
102131
},
103132
AuthTypes: []string{"user", "bot"},
104133
Flags: []common.Flag{
105-
{Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true},
106-
{Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}},
134+
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
135+
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
107136
{Name: "content", Desc: "reply_elements JSON string", Required: true},
108137
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
109138
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
@@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{
145174
}
146175
return nil
147176
}
148-
149177
selection := runtime.Str("selection-with-ellipsis")
150178
blockID := strings.TrimSpace(runtime.Str("block-id"))
151179
if strings.TrimSpace(selection) != "" && blockID != "" {
@@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{
156184
}
157185

158186
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
187+
if docRef.Kind == "file" {
188+
return validateFileCommentMode(mode, "")
189+
}
159190
if mode == commentModeLocal && docRef.Kind == "doc" {
160191
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
161192
}
@@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{
217248
Body(commentBody).
218249
Set("file_token", resolvedToken)
219250
}
251+
if resolvedKind == "file" {
252+
commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
253+
desc := "2-step orchestration: verify supported file metadata -> create file comment"
254+
verifyStep := "[1]"
255+
createStep := "[2]"
256+
if isWiki {
257+
desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment"
258+
verifyStep = "[2]"
259+
createStep = "[3]"
260+
}
261+
return common.NewDryRunAPI().
262+
Desc(desc).
263+
POST("/open-apis/drive/v1/metas/batch_query").
264+
Desc(verifyStep+" Read file metadata and verify the title extension is supported").
265+
Body(map[string]interface{}{
266+
"request_docs": []map[string]interface{}{
267+
{
268+
"doc_token": resolvedToken,
269+
"doc_type": "file",
270+
},
271+
},
272+
}).
273+
POST("/open-apis/drive/v1/files/:file_token/new_comments").
274+
Desc(createStep+" Create file full comment").
275+
Body(commentBody).
276+
Set("file_token", resolvedToken)
277+
}
220278

221279
// Doc/docx comment dry-run.
222280
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
@@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{
317375
if target.FileType == "slides" {
318376
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
319377
}
378+
if target.FileType == "file" {
379+
return executeFileComment(runtime, target)
380+
}
320381

321382
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
322383
if err != nil {
@@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
421482
if token, ok := extractURLToken(raw, "/sheets/"); ok {
422483
return commentDocRef{Kind: "sheet", Token: token}, nil
423484
}
485+
if token, ok := extractURLToken(raw, "/file/"); ok {
486+
return commentDocRef{Kind: "file", Token: token}, nil
487+
}
424488
if token, ok := extractURLToken(raw, "/slides/"); ok {
425489
return commentDocRef{Kind: "slides", Token: token}, nil
426490
}
@@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
431495
return commentDocRef{Kind: "doc", Token: token}, nil
432496
}
433497
if strings.Contains(raw, "://") {
434-
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw)
498+
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
435499
}
436500
if strings.ContainsAny(raw, "/?#") {
437501
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
@@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
440504
// Bare token: --type is required.
441505
docType = strings.TrimSpace(docType)
442506
if docType == "" {
443-
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
507+
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
444508
}
445509
return commentDocRef{Kind: docType, Token: raw}, nil
446510
}
@@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
451515
return resolvedCommentTarget{}, err
452516
}
453517

454-
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
455-
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" {
456-
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
518+
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
519+
if mode == commentModeLocal {
520+
switch docRef.Kind {
521+
case "doc":
522+
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
523+
case "file":
524+
if err := validateFileCommentMode(mode, ""); err != nil {
525+
return resolvedCommentTarget{}, err
526+
}
527+
}
457528
}
458529
return resolvedCommentTarget{
459530
DocID: docRef.Token,
@@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
507578
WikiToken: docRef.Token,
508579
}, nil
509580
}
581+
if objType == "file" {
582+
if err := validateFileCommentMode(mode, objType); err != nil {
583+
return resolvedCommentTarget{}, err
584+
}
585+
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
586+
return resolvedCommentTarget{
587+
DocID: objToken,
588+
FileToken: objToken,
589+
FileType: "file",
590+
ResolvedBy: "wiki",
591+
WikiToken: docRef.Token,
592+
}, nil
593+
}
510594
if mode == commentModeLocal && objType != "docx" {
511595
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
512596
}
513597
if mode == commentModeFull && objType != "docx" && objType != "doc" {
514-
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
598+
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
515599
}
516600

517601
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
718802
"sheet_col": sheet.Col,
719803
"sheet_row": sheet.Row,
720804
}
805+
} else if fileType == "file" {
806+
body["anchor"] = map[string]interface{}{
807+
"block_id": fileFullCommentAnchorBlockID,
808+
}
721809
} else if strings.TrimSpace(blockID) != "" {
722810
body["anchor"] = map[string]interface{}{
723811
"block_id": blockID,
@@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
809897
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
810898
}
811899

900+
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
901+
data, err := runtime.CallAPI(
902+
"POST",
903+
"/open-apis/drive/v1/metas/batch_query",
904+
nil,
905+
map[string]interface{}{
906+
"request_docs": []map[string]interface{}{
907+
{
908+
"doc_token": fileToken,
909+
"doc_type": "file",
910+
},
911+
},
912+
},
913+
)
914+
if err != nil {
915+
return "", err
916+
}
917+
918+
metas := common.GetSlice(data, "metas")
919+
if len(metas) == 0 {
920+
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
921+
}
922+
meta, ok := metas[0].(map[string]interface{})
923+
if !ok {
924+
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
925+
}
926+
return common.GetString(meta, "title"), nil
927+
}
928+
929+
func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) {
930+
title, err := fetchCommentTargetFileTitle(runtime, fileToken)
931+
if err != nil {
932+
return "", "", err
933+
}
934+
extension := fileCommentExtension(title)
935+
if isSupportedFileCommentExtension(extension) {
936+
return title, extension, nil
937+
}
938+
if strings.TrimSpace(title) == "" {
939+
return "", "", output.ErrWithHint(
940+
output.ExitValidation,
941+
"unsupported_file_comment_type",
942+
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
943+
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
944+
)
945+
}
946+
extensionLabel := extension
947+
if extensionLabel == "" {
948+
extensionLabel = "no extension"
949+
}
950+
return "", "", output.ErrWithHint(
951+
output.ExitValidation,
952+
"unsupported_file_comment_type",
953+
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
954+
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
955+
)
956+
}
957+
958+
func fileCommentExtension(title string) string {
959+
title = strings.TrimSpace(title)
960+
idx := strings.LastIndex(title, ".")
961+
if idx == 0 {
962+
extension := strings.ToLower(title)
963+
if isSupportedFileCommentExtension(extension) {
964+
return extension
965+
}
966+
return ""
967+
}
968+
if idx < 0 || idx == len(title)-1 {
969+
return ""
970+
}
971+
return strings.ToLower(title[idx:])
972+
}
973+
974+
func isSupportedFileCommentExtension(extension string) bool {
975+
_, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)]
976+
return ok
977+
}
978+
979+
func supportedFileCommentExtensionsText() string {
980+
return strings.Join(supportedFileCommentExtensions, ", ")
981+
}
982+
983+
func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} {
984+
set := make(map[string]struct{}, len(extensions))
985+
for _, extension := range extensions {
986+
set[extension] = struct{}{}
987+
}
988+
return set
989+
}
990+
991+
func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
992+
if mode != commentModeLocal {
993+
return nil
994+
}
995+
if resolvedObjType != "" {
996+
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
997+
}
998+
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
999+
}
1000+
8121001
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
8131002
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
8141003
if err != nil {
@@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
8491038
return nil
8501039
}
8511040

1041+
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
1042+
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
1043+
if err != nil {
1044+
return err
1045+
}
1046+
1047+
title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken)
1048+
if err != nil {
1049+
return err
1050+
}
1051+
1052+
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
1053+
requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
1054+
1055+
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
1056+
1057+
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
1058+
if err != nil {
1059+
return err
1060+
}
1061+
1062+
out := map[string]interface{}{
1063+
"comment_id": data["comment_id"],
1064+
"doc_id": target.DocID,
1065+
"file_token": target.FileToken,
1066+
"file_type": "file",
1067+
"file_name": title,
1068+
"file_extension": extension,
1069+
"resolved_by": target.ResolvedBy,
1070+
"comment_mode": string(commentModeFull),
1071+
}
1072+
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
1073+
out["created_at"] = createdAt
1074+
}
1075+
if target.WikiToken != "" {
1076+
out["wiki_token"] = target.WikiToken
1077+
}
1078+
1079+
runtime.Out(out, nil)
1080+
return nil
1081+
}
1082+
8521083
func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
8531084
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
8541085
if err != nil {

0 commit comments

Comments
 (0)