gogcli/internal/cmd/comment_ops.go
2026-03-09 00:47:36 +00:00

393 lines
11 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
const (
driveCommentListFields = "nextPageToken"
driveCommentListCoreFields = "comments(id,author,content,createdTime,modifiedTime,resolved,replies)"
driveCommentListQuotedFields = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,replies)"
docsCommentListFields = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,replies(id,author,content,createdTime,modifiedTime,action,deleted))"
driveCommentDetailFields = "id, author, content, createdTime, modifiedTime, resolved, quotedFileContent, anchor, replies"
driveCommentCreateFields = "id, author, content, createdTime, quotedFileContent, anchor"
driveCommentUpdateFields = "id, author, content, modifiedTime"
driveReplyCreateFields = "id, author, content, createdTime"
driveResolveReplyCreateFields = "id, author, content, createdTime, action"
)
type driveCommentListMode int
const (
driveCommentListModeCompact driveCommentListMode = iota
driveCommentListModeExpanded
)
type driveCommentListOptions struct {
resourceKey string
resourceID string
includeResolved bool
includeQuoted bool
scanForOpen bool
page string
all bool
failEmpty bool
max int64
emptyMessage string
mode driveCommentListMode
}
func listDriveComments(ctx context.Context, svc *drive.Service, fileID string, opts driveCommentListOptions) ([]*drive.Comment, string, error) {
fetch := func(pageToken string) ([]*drive.Comment, string, error) {
return fetchDriveCommentsPage(ctx, svc, fileID, opts.max, pageToken, driveCommentFieldsForList(opts))
}
if opts.all {
comments, err := collectAllPages(opts.page, fetch)
if err != nil {
return nil, "", err
}
if !opts.includeResolved {
comments = filterOpenComments(comments)
}
return comments, "", nil
}
if opts.includeResolved || !opts.scanForOpen {
comments, nextPageToken, err := fetch(opts.page)
if err != nil {
return nil, "", err
}
return comments, nextPageToken, nil
}
pageToken := opts.page
for {
pageComments, nextPageToken, err := fetch(pageToken)
if err != nil {
return nil, "", err
}
open := filterOpenComments(pageComments)
if len(open) > 0 || strings.TrimSpace(nextPageToken) == "" {
return open, nextPageToken, nil
}
pageToken = nextPageToken
}
}
func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID string, pageSize int64, pageToken string, commentFields string) ([]*drive.Comment, string, error) {
call := svc.Comments.List(fileID).
IncludeDeleted(false).
PageSize(pageSize).
Fields(gapi.Field(driveCommentListFields), gapi.Field(commentFields)).
Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
}
return resp.Comments, resp.NextPageToken, nil
}
func driveCommentFieldsForList(opts driveCommentListOptions) string {
if opts.mode == driveCommentListModeExpanded {
return docsCommentListFields
}
if opts.includeQuoted {
return driveCommentListQuotedFields
}
return driveCommentListCoreFields
}
func writeDriveCommentList(ctx context.Context, u *ui.UI, opts driveCommentListOptions, comments []*drive.Comment, nextPageToken string) error {
if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
opts.resourceKey: opts.resourceID,
"comments": comments,
"nextPageToken": nextPageToken,
}, len(comments), opts.failEmpty)
}
if len(comments) == 0 {
u.Err().Println(opts.emptyMessage)
return failEmptyExit(opts.failEmpty)
}
if opts.mode == driveCommentListModeExpanded {
printExpandedCommentTable(ctx, comments)
} else {
printCompactCommentTable(ctx, comments, opts.includeQuoted)
}
printNextPageHint(u, nextPageToken)
return nil
}
func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) {
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION")
for _, comment := range comments {
if comment == nil {
continue
}
author := ""
if comment.Author != nil {
author = comment.Author.DisplayName
}
quoted := ""
if comment.QuotedFileContent != nil {
quoted = truncateString(oneLineTSV(comment.QuotedFileContent.Value), 30)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\n",
"comment",
comment.Id,
oneLineTSV(author),
quoted,
truncateString(oneLineTSV(comment.Content), 50),
formatDateTime(comment.CreatedTime),
comment.Resolved,
"",
)
for _, reply := range comment.Replies {
if reply == nil {
continue
}
author = ""
if reply.Author != nil {
author = reply.Author.DisplayName
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
"reply",
reply.Id,
oneLineTSV(author),
"",
truncateString(oneLineTSV(reply.Content), 50),
formatDateTime(reply.CreatedTime),
"",
oneLineTSV(reply.Action),
)
}
}
}
func printCompactCommentTable(ctx context.Context, comments []*drive.Comment, includeQuoted bool) {
w, flush := tableWriter(ctx)
defer flush()
if includeQuoted {
fmt.Fprintln(w, "ID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tREPLIES")
} else {
fmt.Fprintln(w, "ID\tAUTHOR\tCONTENT\tCREATED\tRESOLVED\tREPLIES")
}
for _, comment := range comments {
if comment == nil {
continue
}
author := ""
if comment.Author != nil {
author = comment.Author.DisplayName
}
content := truncateString(comment.Content, 50)
replyCount := len(comment.Replies)
if includeQuoted {
quoted := ""
if comment.QuotedFileContent != nil {
quoted = truncateString(comment.QuotedFileContent.Value, 30)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%d\n",
comment.Id,
author,
quoted,
content,
formatDateTime(comment.CreatedTime),
comment.Resolved,
replyCount,
)
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\t%d\n",
comment.Id,
author,
content,
formatDateTime(comment.CreatedTime),
comment.Resolved,
replyCount,
)
}
}
func getDriveComment(ctx context.Context, svc *drive.Service, fileID, commentID string) (*drive.Comment, error) {
return svc.Comments.Get(fileID, commentID).
Fields(driveCommentDetailFields).
Context(ctx).
Do()
}
func writeDriveCommentDetail(ctx context.Context, u *ui.UI, comment *drive.Comment, includeAnchor, includeReplyDetails bool) error {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"comment": comment})
}
u.Out().Printf("id\t%s", comment.Id)
if comment.Author != nil {
u.Out().Printf("author\t%s", comment.Author.DisplayName)
}
u.Out().Printf("content\t%s", comment.Content)
u.Out().Printf("created\t%s", comment.CreatedTime)
u.Out().Printf("modified\t%s", comment.ModifiedTime)
u.Out().Printf("resolved\t%t", comment.Resolved)
if comment.QuotedFileContent != nil && comment.QuotedFileContent.Value != "" {
u.Out().Printf("quoted\t%s", comment.QuotedFileContent.Value)
}
if includeAnchor && strings.TrimSpace(comment.Anchor) != "" {
u.Out().Printf("anchor\t%s", comment.Anchor)
}
if len(comment.Replies) > 0 {
u.Out().Printf("replies\t%d", len(comment.Replies))
}
if includeReplyDetails {
for _, reply := range comment.Replies {
if reply == nil {
continue
}
author := ""
if reply.Author != nil {
author = reply.Author.DisplayName
}
action := ""
if strings.TrimSpace(reply.Action) != "" {
action = reply.Action
}
u.Out().Printf(" reply\t%s\t%s\t%s\t%s", reply.Id, author, truncateString(reply.Content, 60), action)
}
}
return nil
}
func createDriveComment(ctx context.Context, svc *drive.Service, fileID, content, quoted, anchor string) (*drive.Comment, error) {
comment := &drive.Comment{Content: content}
if quoted != "" {
comment.QuotedFileContent = &drive.CommentQuotedFileContent{Value: quoted}
}
if anchor != "" {
comment.Anchor = anchor
}
return svc.Comments.Create(fileID, comment).
Fields(driveCommentCreateFields).
Context(ctx).
Do()
}
func updateDriveComment(ctx context.Context, svc *drive.Service, fileID, commentID, content string) (*drive.Comment, error) {
return svc.Comments.Update(fileID, commentID, &drive.Comment{Content: content}).
Fields(driveCommentUpdateFields).
Context(ctx).
Do()
}
func writeDriveCommentMutation(ctx context.Context, u *ui.UI, comment *drive.Comment, includeAnchor bool) error {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"comment": comment})
}
u.Out().Printf("id\t%s", comment.Id)
u.Out().Printf("content\t%s", comment.Content)
if comment.CreatedTime != "" {
u.Out().Printf("created\t%s", comment.CreatedTime)
}
if comment.ModifiedTime != "" {
u.Out().Printf("modified\t%s", comment.ModifiedTime)
}
if includeAnchor && strings.TrimSpace(comment.Anchor) != "" {
u.Out().Printf("anchor\t%s", comment.Anchor)
}
return nil
}
func createDriveReply(ctx context.Context, svc *drive.Service, fileID, commentID, content string) (*drive.Reply, error) {
return svc.Replies.Create(fileID, commentID, &drive.Reply{Content: content}).
Fields(driveReplyCreateFields).
Context(ctx).
Do()
}
func resolveDriveComment(ctx context.Context, svc *drive.Service, fileID, commentID, message string) (*drive.Reply, error) {
reply := &drive.Reply{Action: "resolve"}
if msg := strings.TrimSpace(message); msg != "" {
reply.Content = msg
}
return svc.Replies.Create(fileID, commentID, reply).
Fields(driveResolveReplyCreateFields).
Context(ctx).
Do()
}
func writeDriveReplyMutation(ctx context.Context, u *ui.UI, reply *drive.Reply, resolved bool, resourceKey, resourceID, commentID string) error {
if outfmt.IsJSON(ctx) {
if resolved {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"resolved": true,
resourceKey: resourceID,
"commentId": commentID,
"reply": reply,
})
}
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"reply": reply})
}
if resolved {
u.Out().Printf("resolved\ttrue")
u.Out().Printf("%s\t%s", resourceKey, resourceID)
u.Out().Printf("commentId\t%s", commentID)
return nil
}
u.Out().Printf("id\t%s", reply.Id)
u.Out().Printf("content\t%s", reply.Content)
u.Out().Printf("created\t%s", reply.CreatedTime)
return nil
}
func deleteDriveComment(ctx context.Context, svc *drive.Service, fileID, commentID string) error {
return svc.Comments.Delete(fileID, commentID).Context(ctx).Do()
}
func filterOpenComments(comments []*drive.Comment) []*drive.Comment {
var open []*drive.Comment
for _, comment := range comments {
if comment == nil {
continue
}
if !comment.Resolved {
open = append(open, comment)
}
}
return open
}
func oneLineTSV(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.ReplaceAll(s, "\t", " ")
s = strings.ReplaceAll(s, "\n", "\\n")
return strings.TrimSpace(s)
}
func truncateString(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}