@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
4747// with `drive file.comments create_v2` against a fresh docx.
4848const 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+
5078type commentDocRef struct {
5179 Kind string
5280 Token string
@@ -93,17 +121,18 @@ const (
93121var 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+
8121001func 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+
8521083func executeSlidesComment (runtime * common.RuntimeContext , docRef commentDocRef ) error {
8531084 replyElements , err := parseCommentReplyElements (runtime .Str ("content" ))
8541085 if err != nil {
0 commit comments