Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions cmd/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/4ier/notion-cli/internal/client"
Expand Down Expand Up @@ -317,9 +318,106 @@ func init() {
commentListCmd.Flags().Bool("all", false, "Fetch all pages of results")
commentAddCmd.Flags().String("text", "", "Comment text")
commentAddCmd.Flags().StringArray("mention-user", nil, "Mention a Notion user by ID (repeatable)")
commentUpdateCmd.Flags().String("text", "", "New comment text (required)")
commentUpdateCmd.Flags().StringArray("mention-user", nil, "Mention a Notion user by ID (repeatable)")

commentCmd.AddCommand(commentListCmd)
commentCmd.AddCommand(commentAddCmd)
commentCmd.AddCommand(commentGetCmd)
commentCmd.AddCommand(commentReplyCmd)
commentCmd.AddCommand(commentUpdateCmd)
commentCmd.AddCommand(commentDeleteCmd)
}

var commentUpdateCmd = &cobra.Command{
Use: "update <comment-id>",
Short: "Edit an existing comment's text",
Long: `Edit the text of an existing comment.

Wraps PATCH /v1/comments/:id (added in Notion's 2025 API). The new
rich_text is built the same way as 'comment add' — --mention-user works
if you need to keep or add @mentions.

Examples:
notion comment update abc123 --text "Fixed typo in previous comment"
notion comment update abc123 --text "with mention" --mention-user <user-id>`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}

commentID := strings.TrimSpace(args[0])
text, _ := cmd.Flags().GetString("text")
mentionUserIDs, _ := cmd.Flags().GetStringArray("mention-user")

if text == "" && len(mentionUserIDs) == 0 {
return fmt.Errorf("--text or --mention-user is required")
}

c := client.New(token)
c.SetDebug(debugMode)

data, err := c.UpdateComment(commentID, text, mentionUserIDs)
if err != nil {
return fmt.Errorf("update comment: %w", err)
}

if outputFormat == "json" {
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return render.JSON(result)
}

fmt.Println("✓ Comment updated")
return nil
},
}

var commentDeleteCmd = &cobra.Command{
Use: "delete <comment-id ...>",
Short: "Delete one or more comments",
Long: `Delete comments by id. Accepts multiple ids for bulk removal,
mirroring 'block delete' — per-id errors are printed but do not stop
the batch.

Note: deleting the anchor (first) comment of a discussion removes the
whole thread. Deleting a reply removes just that one reply.

Wraps DELETE /v1/comments/:id (added in Notion's 2025 API).

Examples:
notion comment delete abc123
notion comment delete abc123 def456 ghi789`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}

c := client.New(token)
c.SetDebug(debugMode)

deleted := 0
for _, id := range args {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, err := c.DeleteComment(id); err != nil {
fmt.Fprintf(os.Stderr, "✗ Failed to delete %s: %v\n", id, err)
continue
}
deleted++
}

if outputFormat != "json" {
fmt.Printf("✓ %d comment(s) deleted\n", deleted)
}
return nil
},
}
37 changes: 37 additions & 0 deletions cmd/comment_update_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cmd

import (
"testing"
)

func TestCommentUpdateCmd_RequiresTextOrMention(t *testing.T) {
if commentUpdateCmd.Short == "" {
t.Error("update short help missing")
}
// Sanity: the command is registered under the parent.
for _, expected := range []string{"update", "delete"} {
found := false
for _, sub := range commentCmd.Commands() {
if sub.Name() == expected {
found = true
break
}
}
if !found {
t.Errorf("comment %s subcommand not registered", expected)
}
}
}

func TestCommentDeleteCmd_AcceptsVariadic(t *testing.T) {
// Args validator should accept 1+ ids.
if err := commentDeleteCmd.Args(commentDeleteCmd, []string{"id1"}); err != nil {
t.Errorf("one id should be valid: %v", err)
}
if err := commentDeleteCmd.Args(commentDeleteCmd, []string{"id1", "id2", "id3"}); err != nil {
t.Errorf("multiple ids should be valid: %v", err)
}
if err := commentDeleteCmd.Args(commentDeleteCmd, []string{}); err == nil {
t.Errorf("zero ids should fail")
}
}
17 changes: 17 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,23 @@ func (c *Client) AddComment(pageID, text string, mentionUserIDs []string) ([]byt
return c.Post("/v1/comments", body)
}

// UpdateComment edits the rich_text body of an existing comment.
// Wraps PATCH /v1/comments/:id (added in Notion API 2025).
func (c *Client) UpdateComment(commentID, text string, mentionUserIDs []string) ([]byte, error) {
body := map[string]interface{}{
"rich_text": buildCommentRichText(text, mentionUserIDs),
}
return c.Patch("/v1/comments/"+commentID, body)
}

// DeleteComment removes a comment by id.
// Wraps DELETE /v1/comments/:id (added in Notion API 2025). When the
// target is the anchor comment of a discussion, Notion removes the whole
// thread; otherwise it removes just that one reply.
func (c *Client) DeleteComment(commentID string) ([]byte, error) {
return c.Delete("/v1/comments/" + commentID)
}

func buildCommentRichText(text string, mentionUserIDs []string) []map[string]interface{} {
var richText []map[string]interface{}

Expand Down
Loading