diff --git a/cmd/comment.go b/cmd/comment.go index fdf7e06..febefbf 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "os" "strings" "github.com/4ier/notion-cli/internal/client" @@ -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 ", + 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 `, + 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 ", + 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 + }, } diff --git a/cmd/comment_update_delete_test.go b/cmd/comment_update_delete_test.go new file mode 100644 index 0000000..7eac1b0 --- /dev/null +++ b/cmd/comment_update_delete_test.go @@ -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") + } +} diff --git a/internal/client/client.go b/internal/client/client.go index a3b7014..501ec16 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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{}