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
92 changes: 79 additions & 13 deletions cmd/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,28 @@ Examples:

var blockUpdateCmd = &cobra.Command{
Use: "update <block-id|url>",
Short: "Update a block",
Long: `Update a block's content.
Short: "Update a block's content",
Long: `Update a block's content in place.

Accepts one of:
--text <str> plain text (stored as a single rich_text with no
inline annotations). Default when nothing else is set.
--markdown when combined with --text, runs the string through
the markdown inline parser so **bold**, *italic*,
` + "`code`" + `, ~~strike~~, and [links](u) are preserved.
--file <path> read markdown from a file; content must map to
exactly one block. Code-fence language aliases and
inline formatting are applied the same way as
'block append --file'.

The Notion API does not let you change a block's type via PATCH, so if
--file parses into a block type different from the existing one the
command fails fast.

Examples:
notion block update abc123 --text "Updated content"
notion block update abc123 --text "See **[design](u)** doc" --markdown
notion block update abc123 --file patch.md
notion block update abc123 --type paragraph --text "New text"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -141,11 +158,23 @@ Examples:
blockID := util.ResolveID(args[0])
text, _ := cmd.Flags().GetString("text")
blockType, _ := cmd.Flags().GetString("type")
filePath, _ := cmd.Flags().GetString("file")
markdown, _ := cmd.Flags().GetBool("markdown")

if text != "" && filePath != "" {
return fmt.Errorf("--text and --file are mutually exclusive")
}
if markdown && filePath != "" {
return fmt.Errorf("--markdown is implied for --file; drop --markdown when using --file")
}
if text == "" && filePath == "" {
return fmt.Errorf("one of --text or --file is required")
}

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

// If no type specified, get the block first to determine its type
// Resolve target type: user override wins, otherwise inspect the block.
if blockType == "" {
block, err := c.GetBlock(blockID)
if err != nil {
Expand All @@ -155,17 +184,13 @@ Examples:
} else {
blockType = mapBlockType(blockType)
}

if text == "" {
return fmt.Errorf("--text is required")
if blockType == "" {
return fmt.Errorf("could not determine block type; pass --type explicitly")
}

body := map[string]interface{}{
blockType: map[string]interface{}{
"rich_text": []map[string]interface{}{
{"text": map[string]interface{}{"content": text}},
},
},
body, err := buildUpdateBlockBody(blockType, text, filePath, markdown)
if err != nil {
return err
}

data, err := c.Patch("/v1/blocks/"+blockID, body)
Expand All @@ -186,6 +211,45 @@ Examples:
},
}

// buildUpdateBlockBody assembles the PATCH body for a single-block update.
// It encapsulates the three supported input modes (--text plain, --text with
// --markdown, --file markdown) so RunE stays thin and the logic is testable.
func buildUpdateBlockBody(blockType, text, filePath string, markdown bool) (map[string]interface{}, error) {
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
blocks := parseMarkdownToBlocks(string(data))
if len(blocks) != 1 {
return nil, fmt.Errorf("--file must contain exactly one block, got %d; use 'block delete' + 'block append' for multi-block replacements", len(blocks))
}
parsed := blocks[0]
parsedType, _ := parsed["type"].(string)
if parsedType != blockType {
return nil, fmt.Errorf("block type mismatch: target is %q but --file parsed as %q (Notion's PATCH cannot change block type)", blockType, parsedType)
}
// Preserve any type-specific fields (language on code, checked on to_do) from the parsed block.
inner, _ := parsed[blockType].(map[string]interface{})
return map[string]interface{}{blockType: inner}, nil
}

// --text path
var richText []map[string]interface{}
if markdown {
richText = parseInlineFormatting(text)
} else {
richText = []map[string]interface{}{
{"text": map[string]interface{}{"content": text}},
}
}
return map[string]interface{}{
blockType: map[string]interface{}{
"rich_text": richText,
},
}, nil
}

var blockAppendCmd = &cobra.Command{
Use: "append <parent-id|url> [text]",
Short: "Append blocks to a page",
Expand Down Expand Up @@ -616,8 +680,10 @@ func init() {
blockListCmd.Flags().Bool("all", false, "Fetch all pages of results")
blockListCmd.Flags().Int("depth", 1, "Depth of nested blocks to fetch (default 1)")
blockListCmd.Flags().Bool("md", false, "Output as Markdown")
blockUpdateCmd.Flags().String("text", "", "New text content (required)")
blockUpdateCmd.Flags().String("text", "", "New text content (mutually exclusive with --file)")
blockUpdateCmd.Flags().StringP("type", "t", "", "Block type (auto-detected if not specified)")
blockUpdateCmd.Flags().String("file", "", "Read markdown from file; must parse to exactly one block")
blockUpdateCmd.Flags().Bool("markdown", false, "Parse --text as markdown (bold/italic/code/link)")
blockMoveCmd.Flags().String("after", "", "Block ID to position after")
blockMoveCmd.Flags().String("before", "", "Block ID to position before")
blockMoveCmd.Flags().String("parent", "", "New parent block/page ID to move to")
Expand Down
120 changes: 120 additions & 0 deletions cmd/block_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestBuildUpdateBlockBody_PlainText(t *testing.T) {
body, err := buildUpdateBlockBody("paragraph", "hello", "", false)
if err != nil {
t.Fatal(err)
}
p := body["paragraph"].(map[string]interface{})
rt := p["rich_text"].([]map[string]interface{})
if len(rt) != 1 {
t.Fatalf("expected 1 rich_text, got %d", len(rt))
}
text := rt[0]["text"].(map[string]interface{})
if text["content"] != "hello" {
t.Errorf("content = %v, want hello", text["content"])
}
if _, hasAnn := rt[0]["annotations"]; hasAnn {
t.Error("plain text should have no annotations")
}
}

func TestBuildUpdateBlockBody_TextWithMarkdown(t *testing.T) {
body, err := buildUpdateBlockBody("paragraph", "See **bold** text", "", true)
if err != nil {
t.Fatal(err)
}
p := body["paragraph"].(map[string]interface{})
rt := p["rich_text"].([]map[string]interface{})
// "See " (plain), "bold" (bold), " text" (plain) → 3 parts
if len(rt) != 3 {
t.Fatalf("expected 3 rich_text parts, got %d: %+v", len(rt), rt)
}
// middle part should be bold
ann, _ := rt[1]["annotations"].(map[string]interface{})
if ann["bold"] != true {
t.Errorf("middle part should be bold: %+v", rt[1])
}
}

func TestBuildUpdateBlockBody_FileMarkdown(t *testing.T) {
tmp := t.TempDir()
mdPath := filepath.Join(tmp, "patch.md")
if err := os.WriteFile(mdPath, []byte("Updated via **file**"), 0600); err != nil {
t.Fatal(err)
}

body, err := buildUpdateBlockBody("paragraph", "", mdPath, false)
if err != nil {
t.Fatal(err)
}
p := body["paragraph"].(map[string]interface{})
rt := p["rich_text"].([]map[string]interface{})
if len(rt) < 2 {
t.Errorf("expected markdown to produce inline parts, got %d", len(rt))
}
}

func TestBuildUpdateBlockBody_FileCodeBlockPreservesLanguage(t *testing.T) {
tmp := t.TempDir()
mdPath := filepath.Join(tmp, "patch.md")
if err := os.WriteFile(mdPath, []byte("```ts\nconst x: number = 1;\n```"), 0600); err != nil {
t.Fatal(err)
}

body, err := buildUpdateBlockBody("code", "", mdPath, false)
if err != nil {
t.Fatal(err)
}
code := body["code"].(map[string]interface{})
if code["language"] != "typescript" {
t.Errorf("language = %v, want typescript (ts alias normalization)", code["language"])
}
}

func TestBuildUpdateBlockBody_FileTypeMismatch(t *testing.T) {
tmp := t.TempDir()
mdPath := filepath.Join(tmp, "patch.md")
// File has a heading, but target is paragraph — should error.
if err := os.WriteFile(mdPath, []byte("# Heading"), 0600); err != nil {
t.Fatal(err)
}

_, err := buildUpdateBlockBody("paragraph", "", mdPath, false)
if err == nil {
t.Fatal("expected type mismatch error")
}
if !strings.Contains(err.Error(), "block type mismatch") {
t.Errorf("error should mention type mismatch: %v", err)
}
}

func TestBuildUpdateBlockBody_FileMustBeSingleBlock(t *testing.T) {
tmp := t.TempDir()
mdPath := filepath.Join(tmp, "patch.md")
if err := os.WriteFile(mdPath, []byte("first block\n\nsecond block"), 0600); err != nil {
t.Fatal(err)
}

_, err := buildUpdateBlockBody("paragraph", "", mdPath, false)
if err == nil {
t.Fatal("expected multi-block error")
}
if !strings.Contains(err.Error(), "exactly one block") {
t.Errorf("error should mention one-block requirement: %v", err)
}
}

func TestBuildUpdateBlockBody_FileMissing(t *testing.T) {
_, err := buildUpdateBlockBody("paragraph", "", "/nonexistent/path.md", false)
if err == nil || !strings.Contains(err.Error(), "read file") {
t.Errorf("expected read error, got %v", err)
}
}
Loading