From 54d794e8c381da7c8bf8eebc94d28b57815fe575 Mon Sep 17 00:00:00 2001 From: 4ier Date: Thu, 30 Apr 2026 14:05:48 +0800 Subject: [PATCH] feat(block): 'block update' accepts --file markdown and --markdown for inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #36. Brings 'block update' in line with 'block append' / 'block insert', which have supported markdown input since earlier releases. New flags: --file read markdown from a file; must parse to exactly one block. Fails fast on type mismatch because Notion's PATCH /v1/blocks/:id cannot change a block's type. Code-fence language aliases from #22 apply here, so --file with a 'ts' / 'sh' / 'yml' fence still works. --markdown when combined with --text, runs the inline parser (bold / italic / code / strikethrough / links). Mutual-exclusion and validation: - --text and --file are mutually exclusive. - --markdown is implied for --file; supplying both is rejected. - At least one of --text / --file is required. - --file preserves type-specific inner fields (code.language, to_do. checked, etc.) from the parsed block. Logic extracted to buildUpdateBlockBody() so it's unit-testable without network. Tests cover plain text, --markdown annotations, --file parsing with language normalization, type mismatch, multi-block rejection, and missing-file errors. Smoke-tested end-to-end: paragraph → plain update, paragraph → markdown update with links/bold, code block → sh-alias update normalized to 'shell', and the type-mismatch guard fires correctly. --- cmd/block.go | 92 +++++++++++++++++++++++++----- cmd/block_update_test.go | 120 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 cmd/block_update_test.go diff --git a/cmd/block.go b/cmd/block.go index fe7a68a..63d1af1 100644 --- a/cmd/block.go +++ b/cmd/block.go @@ -125,11 +125,28 @@ Examples: var blockUpdateCmd = &cobra.Command{ Use: "update ", - 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 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 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 { @@ -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 { @@ -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) @@ -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 [text]", Short: "Append blocks to a page", @@ -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") diff --git a/cmd/block_update_test.go b/cmd/block_update_test.go new file mode 100644 index 0000000..7d30dfa --- /dev/null +++ b/cmd/block_update_test.go @@ -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) + } +}