Files
傅洋 89f9e909fe feat(block): 'block update' accepts --file markdown and --markdown for inline (#45)
Closes #36. Brings 'block update' in line with 'block append' / 'block
insert', which have supported markdown input since earlier releases.

New flags:
  --file <path>   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.
2026-04-30 14:06:05 +08:00

1383 lines
39 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"github.com/4ier/notion-cli/internal/client"
"github.com/4ier/notion-cli/internal/render"
"github.com/4ier/notion-cli/internal/util"
"github.com/spf13/cobra"
)
var blockCmd = &cobra.Command{
Use: "block",
Short: "Work with content blocks",
}
var blockListCmd = &cobra.Command{
Use: "list <parent-id|url>",
Short: "List child blocks",
Long: `List all child blocks of a page or block.
Examples:
notion block list <page-id>
notion block list <page-id> --format json
notion block list <page-id> --all
notion block list <page-id> --depth 2`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}
parentID := util.ResolveID(args[0])
all, _ := cmd.Flags().GetBool("all")
cursor, _ := cmd.Flags().GetString("cursor")
depth, _ := cmd.Flags().GetInt("depth")
if depth < 1 {
depth = 1
}
c := client.New(token)
c.SetDebug(debugMode)
allResults, err := fetchBlockChildren(c, parentID, cursor, all)
if err != nil {
return err
}
// Recursively fetch nested children
if depth > 1 {
allResults = fetchNestedBlocks(c, allResults, depth-1)
}
if outputFormat == "json" {
return render.JSON(map[string]interface{}{"results": allResults})
}
mdMode, _ := cmd.Flags().GetBool("md")
if outputFormat == "md" || outputFormat == "markdown" {
mdMode = true
}
for _, b := range allResults {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
if mdMode {
renderBlockMarkdown(block, 0)
} else {
renderBlockRecursive(block, 0)
}
}
return nil
},
}
var blockGetCmd = &cobra.Command{
Use: "get <block-id|url>",
Short: "Get a specific block",
Long: `Retrieve a single block by ID.
Examples:
notion block get abc123
notion block get abc123 --format json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}
blockID := util.ResolveID(args[0])
c := client.New(token)
c.SetDebug(debugMode)
block, err := c.GetBlock(blockID)
if err != nil {
return fmt.Errorf("get block: %w", err)
}
if outputFormat == "json" {
return render.JSON(block)
}
blockType, _ := block["type"].(string)
id, _ := block["id"].(string)
hasChildren, _ := block["has_children"].(bool)
render.Title("🧱", fmt.Sprintf("Block: %s", blockType))
render.Field("ID", id)
render.Field("Type", blockType)
render.Field("Has Children", fmt.Sprintf("%v", hasChildren))
fmt.Println()
renderBlock(block, 0)
return nil
},
}
var blockUpdateCmd = &cobra.Command{
Use: "update <block-id|url>",
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 {
token, err := getToken()
if err != nil {
return err
}
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)
// Resolve target type: user override wins, otherwise inspect the block.
if blockType == "" {
block, err := c.GetBlock(blockID)
if err != nil {
return fmt.Errorf("get block: %w", err)
}
blockType, _ = block["type"].(string)
} else {
blockType = mapBlockType(blockType)
}
if blockType == "" {
return fmt.Errorf("could not determine block type; pass --type explicitly")
}
body, err := buildUpdateBlockBody(blockType, text, filePath, markdown)
if err != nil {
return err
}
data, err := c.Patch("/v1/blocks/"+blockID, body)
if err != nil {
return fmt.Errorf("update block: %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("✓ Block updated")
return nil
},
}
// 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",
Long: `Append content to a Notion page or block.
Supports plain text, block types, markdown files, and media blocks.
Media blocks accept one of:
--image-url/--image-file/--image-upload (and the same pattern for
file/video/audio/pdf). See 'notion block append --help' for the full list.
Large markdown files are handled transparently:
- >100 children are auto-batched into sequential PATCHes.
- code / rich_text exceeding Notion's 2000-char limit are split by
default (override with --on-oversize=truncate|fail).
Examples:
notion block append <page-id> "Hello world"
notion block append <page-id> --type heading1 "Section Title"
notion block append <page-id> --type code --lang go "fmt.Println()"
notion block append <page-id> --file notes.md
notion block append <page-id> --file big.md --on-oversize=truncate
notion block append <page-id> --image-url https://example.com/a.png --caption "图 1-1"
notion block append <page-id> --image-file ./chart.png --caption "heap usage"
notion block append <page-id> --pdf-upload 351d45fb-... --caption "spec v2"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}
parentID := util.ResolveID(args[0])
blockType, _ := cmd.Flags().GetString("type")
filePath, _ := cmd.Flags().GetString("file")
onOversizeRaw, _ := cmd.Flags().GetString("on-oversize")
mode, err := parseOversizeMode(onOversizeRaw)
if err != nil {
return err
}
text := ""
if len(args) > 1 {
text = args[1]
}
mediaSrc, err := resolveMediaSource(cmd, filePath, text)
if err != nil {
return err
}
if blockType == "" {
blockType = "paragraph"
}
c := client.New(token)
c.SetDebug(debugMode)
var children []map[string]interface{}
if mediaSrc.IsActive() {
block, err := mediaSrc.Build(c)
if err != nil {
return err
}
children = append(children, block)
} else if filePath != "" {
// Read file and parse markdown to blocks
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
children = parseMarkdownToBlocks(string(data))
} else {
if text == "" {
return fmt.Errorf("text content, --file, or a media source (--image-url, --image-file, --image-upload, ...) is required")
}
notionType := mapBlockType(blockType)
blockContent := map[string]interface{}{
"rich_text": []map[string]interface{}{
{"text": map[string]interface{}{"content": text}},
},
}
if notionType == "code" {
lang, _ := cmd.Flags().GetString("lang")
blockContent["language"] = normalizeCodeLanguage(lang)
}
children = append(children, map[string]interface{}{
"object": "block",
"type": notionType,
notionType: blockContent,
})
}
if len(children) == 0 {
return fmt.Errorf("no content to append")
}
children, err = handleOversizedBlocks(children, mode)
if err != nil {
return err
}
data, err := appendChildrenBatched(c, parentID, "", children)
if err != nil {
return fmt.Errorf("append block: %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.Printf("✓ %d block(s) appended\n", len(children))
return nil
},
}
var blockDeleteCmd = &cobra.Command{
Use: "delete <block-id ...>",
Short: "Delete one or more blocks",
Long: `Delete blocks by ID. Supports multiple IDs.
Examples:
notion block delete abc123
notion block 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 _, arg := range args {
blockID := util.ResolveID(arg)
_, err = c.Delete("/v1/blocks/" + blockID)
if err != nil {
fmt.Fprintf(os.Stderr, "✗ Failed to delete %s: %v\n", blockID, err)
continue
}
deleted++
}
if outputFormat != "json" {
fmt.Printf("✓ %d block(s) deleted\n", deleted)
}
return nil
},
}
var blockInsertCmd = &cobra.Command{
Use: "insert <parent-id|url> [text]",
Short: "Insert a block after a specific block",
Long: `Insert content after a specific child block within a parent.
Examples:
notion block insert <page-id> "New paragraph" --after <block-id>
notion block insert <page-id> "Section" --after <block-id> --type h2
notion block insert <page-id> --file notes.md --after <block-id>
notion block insert <page-id> --after <block-id> --image-url https://example.com/a.png --caption "图 1-1"
notion block insert <page-id> --after <block-id> --image-file ./chart.png`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}
parentID := util.ResolveID(args[0])
afterID, _ := cmd.Flags().GetString("after")
blockType, _ := cmd.Flags().GetString("type")
filePath, _ := cmd.Flags().GetString("file")
text := ""
if len(args) > 1 {
text = args[1]
}
mediaSrc, err := resolveMediaSource(cmd, filePath, text)
if err != nil {
return err
}
if afterID == "" {
return fmt.Errorf("--after <block-id> is required (use 'block append' to add to end)")
}
afterID = util.ResolveID(afterID)
if blockType == "" {
blockType = "paragraph"
}
c := client.New(token)
c.SetDebug(debugMode)
var children []map[string]interface{}
onOversizeRaw, _ := cmd.Flags().GetString("on-oversize")
mode, err := parseOversizeMode(onOversizeRaw)
if err != nil {
return err
}
if mediaSrc.IsActive() {
block, err := mediaSrc.Build(c)
if err != nil {
return err
}
children = append(children, block)
} else if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
children = parseMarkdownToBlocks(string(data))
} else {
if text == "" {
return fmt.Errorf("text content, --file, or a media source (--image-url, --image-file, --image-upload, ...) is required")
}
notionType := mapBlockType(blockType)
blockContent := map[string]interface{}{
"rich_text": []map[string]interface{}{
{"text": map[string]interface{}{"content": text}},
},
}
if notionType == "code" {
lang, _ := cmd.Flags().GetString("lang")
blockContent["language"] = normalizeCodeLanguage(lang)
}
children = append(children, map[string]interface{}{
"object": "block",
"type": notionType,
notionType: blockContent,
})
}
children, err = handleOversizedBlocks(children, mode)
if err != nil {
return err
}
data, err := appendChildrenBatched(c, parentID, afterID, children)
if err != nil {
return fmt.Errorf("insert block: %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.Printf("✓ %d block(s) inserted\n", len(children))
return nil
},
}
var blockMoveCmd = &cobra.Command{
Use: "move <block-id|url>",
Short: "Move a block to a new position",
Long: `Move a block within its parent or to a different parent.
Use --after to position after a specific block.
Use --before to position before a specific block.
Use --parent to move to a different parent block/page.
Examples:
notion block move abc123 --after def456
notion block move abc123 --before ghi789
notion block move abc123 --parent xyz000
notion block move abc123 --parent xyz000 --after def456`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}
blockID := util.ResolveID(args[0])
afterID, _ := cmd.Flags().GetString("after")
beforeID, _ := cmd.Flags().GetString("before")
parentID, _ := cmd.Flags().GetString("parent")
if afterID == "" && beforeID == "" && parentID == "" {
return fmt.Errorf("at least one of --after, --before, or --parent is required")
}
if afterID != "" && beforeID != "" {
return fmt.Errorf("cannot specify both --after and --before")
}
c := client.New(token)
c.SetDebug(debugMode)
// Get the current block to find its parent if not specified
currentBlock, err := c.GetBlock(blockID)
if err != nil {
return fmt.Errorf("get block: %w", err)
}
// Determine the target parent
targetParentID := parentID
if targetParentID == "" {
// Use the current parent
parent, _ := currentBlock["parent"].(map[string]interface{})
if pid, ok := parent["page_id"].(string); ok {
targetParentID = pid
} else if pid, ok := parent["block_id"].(string); ok {
targetParentID = pid
}
} else {
targetParentID = util.ResolveID(targetParentID)
}
if targetParentID == "" {
return fmt.Errorf("could not determine parent block/page")
}
// Handle --before by finding the block that comes before the target
var afterBlockID string
if beforeID != "" {
beforeID = util.ResolveID(beforeID)
// Get all children of the parent
children, err := fetchBlockChildren(c, targetParentID, "", true)
if err != nil {
return fmt.Errorf("get parent children: %w", err)
}
// Find the block that comes before the target
for i, child := range children {
childBlock, ok := child.(map[string]interface{})
if !ok {
continue
}
childID, _ := childBlock["id"].(string)
if childID == beforeID {
if i > 0 {
// Get the ID of the previous block
prevBlock, _ := children[i-1].(map[string]interface{})
afterBlockID, _ = prevBlock["id"].(string)
}
// If i == 0, afterBlockID stays empty (insert at beginning)
break
}
}
} else if afterID != "" {
afterBlockID = util.ResolveID(afterID)
}
// Build the request body for moving the block
// Note: Notion API uses PATCH /v1/blocks/{id} with parent and after fields
body := map[string]interface{}{}
// Set the parent (try page_id first, then block_id)
// We need to check if the parent is a page or a block
parentBlock, err := c.GetBlock(targetParentID)
if err == nil {
parentType, _ := parentBlock["type"].(string)
if parentType == "child_page" || parentType == "" {
// It's a page
body["parent"] = map[string]interface{}{
"page_id": targetParentID,
}
} else {
// It's a block
body["parent"] = map[string]interface{}{
"block_id": targetParentID,
}
}
} else {
// Assume it's a page if we can't get the block
body["parent"] = map[string]interface{}{
"page_id": targetParentID,
}
}
if afterBlockID != "" {
body["after"] = afterBlockID
}
data, err := c.Patch("/v1/blocks/"+blockID, body)
if err != nil {
return fmt.Errorf("move block: %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)
}
if afterBlockID != "" {
fmt.Printf("✓ Block moved after %s\n", afterBlockID)
} else if beforeID != "" {
fmt.Printf("✓ Block moved before %s\n", beforeID)
} else {
fmt.Printf("✓ Block moved to %s\n", targetParentID)
}
return nil
},
}
func init() {
blockAppendCmd.Flags().StringP("type", "t", "paragraph", "Block type: paragraph, h1, h2, h3, todo, bullet, numbered, quote, code, callout, divider")
blockAppendCmd.Flags().String("lang", "plain text", "Language for code blocks (e.g. go, python, bash)")
blockAppendCmd.Flags().String("file", "", "Read content from a file (each double-newline-separated section becomes a block)")
blockAppendCmd.Flags().String("on-oversize", "split", "Behavior for rich_text >2000 chars: split|truncate|fail")
registerMediaFlags(blockAppendCmd)
blockInsertCmd.Flags().String("after", "", "Block ID to insert after (required)")
blockInsertCmd.Flags().StringP("type", "t", "paragraph", "Block type")
blockInsertCmd.Flags().String("lang", "plain text", "Language for code blocks")
blockInsertCmd.Flags().String("file", "", "Read content from a file")
blockInsertCmd.Flags().String("on-oversize", "split", "Behavior for rich_text >2000 chars: split|truncate|fail")
registerMediaFlags(blockInsertCmd)
blockListCmd.Flags().String("cursor", "", "Pagination cursor")
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 (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")
blockCmd.AddCommand(blockListCmd)
blockCmd.AddCommand(blockGetCmd)
blockCmd.AddCommand(blockAppendCmd)
blockCmd.AddCommand(blockInsertCmd)
blockCmd.AddCommand(blockUpdateCmd)
blockCmd.AddCommand(blockDeleteCmd)
blockCmd.AddCommand(blockMoveCmd)
}
func buildExternalImageBlock(url, caption string) map[string]interface{} {
// Back-compat wrapper; new code should use buildExternalMediaBlock.
return buildExternalMediaBlock("image", url, caption)
}
func validateMediaFlags(imageURL, filePath, text string) error {
// Back-compat shim preserved for existing tests; new flows use
// resolveMediaSource().
if imageURL == "" {
return nil
}
if filePath != "" {
return fmt.Errorf("--image-url cannot be combined with --file")
}
if text != "" {
return fmt.Errorf("--image-url cannot be combined with a positional text argument")
}
if !strings.HasPrefix(imageURL, "http://") && !strings.HasPrefix(imageURL, "https://") {
return fmt.Errorf("--image-url must be an http:// or https:// URL")
}
return nil
}
func mapBlockType(t string) string {
switch t {
case "heading1", "h1":
return "heading_1"
case "heading2", "h2":
return "heading_2"
case "heading3", "h3":
return "heading_3"
case "bullet":
return "bulleted_list_item"
case "numbered":
return "numbered_list_item"
case "todo":
return "to_do"
case "paragraph", "p":
return "paragraph"
case "quote":
return "quote"
case "code":
return "code"
case "callout":
return "callout"
case "divider":
return "divider"
default:
return t
}
}
// fetchBlockChildren fetches all children of a block with optional pagination.
func fetchBlockChildren(c *client.Client, parentID, cursor string, all bool) ([]interface{}, error) {
var allResults []interface{}
currentCursor := cursor
for {
result, err := c.GetBlockChildren(parentID, 100, currentCursor)
if err != nil {
return nil, err
}
results, _ := result["results"].([]interface{})
allResults = append(allResults, results...)
hasMore, _ := result["has_more"].(bool)
if !all || !hasMore {
break
}
nextCursor, _ := result["next_cursor"].(string)
currentCursor = nextCursor
}
return allResults, nil
}
// fetchNestedBlocks recursively fetches children for blocks that have them.
func fetchNestedBlocks(c *client.Client, blocks []interface{}, remainingDepth int) []interface{} {
if remainingDepth <= 0 {
return blocks
}
for _, b := range blocks {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
hasChildren, _ := block["has_children"].(bool)
if !hasChildren {
continue
}
id, _ := block["id"].(string)
if id == "" {
continue
}
children, err := fetchBlockChildren(c, id, "", true)
if err != nil {
continue
}
if remainingDepth > 1 {
children = fetchNestedBlocks(c, children, remainingDepth-1)
}
block["_children"] = children
}
return blocks
}
// renderBlockRecursive renders a block and its nested children.
func renderBlockRecursive(block map[string]interface{}, indent int) {
renderBlock(block, indent)
if children, ok := block["_children"].([]interface{}); ok {
for _, child := range children {
if childBlock, ok := child.(map[string]interface{}); ok {
renderBlockRecursive(childBlock, indent+1)
}
}
}
}
// parseMarkdownToBlocks converts markdown text to Notion block objects.
func parseMarkdownToBlocks(content string) []map[string]interface{} {
var blocks []map[string]interface{}
lines := strings.Split(content, "\n")
i := 0
for i < len(lines) {
line := lines[i]
// Code fence
if strings.HasPrefix(line, "```") {
lang := normalizeCodeLanguage(strings.TrimPrefix(line, "```"))
var codeLines []string
i++
for i < len(lines) && !strings.HasPrefix(lines[i], "```") {
codeLines = append(codeLines, lines[i])
i++
}
i++ // skip closing ```
blocks = append(blocks, map[string]interface{}{
"object": "block",
"type": "code",
"code": map[string]interface{}{
"rich_text": []map[string]interface{}{
{"text": map[string]interface{}{"content": strings.Join(codeLines, "\n")}},
},
"language": lang,
},
})
continue
}
// Skip empty lines
if strings.TrimSpace(line) == "" {
i++
continue
}
// Headings
if strings.HasPrefix(line, "### ") {
blocks = append(blocks, makeTextBlock("heading_3", strings.TrimPrefix(line, "### ")))
i++
continue
}
if strings.HasPrefix(line, "## ") {
blocks = append(blocks, makeTextBlock("heading_2", strings.TrimPrefix(line, "## ")))
i++
continue
}
if strings.HasPrefix(line, "# ") {
blocks = append(blocks, makeTextBlock("heading_1", strings.TrimPrefix(line, "# ")))
i++
continue
}
// Todo (must check before bullet — "- [ ]" starts with "- ")
if strings.HasPrefix(line, "- [ ] ") {
block := makeTextBlock("to_do", line[6:])
block["to_do"].(map[string]interface{})["checked"] = false
blocks = append(blocks, block)
i++
continue
}
if strings.HasPrefix(line, "- [x] ") || strings.HasPrefix(line, "- [X] ") {
block := makeTextBlock("to_do", line[6:])
block["to_do"].(map[string]interface{})["checked"] = true
blocks = append(blocks, block)
i++
continue
}
// Bullet list
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
blocks = append(blocks, makeTextBlock("bulleted_list_item", line[2:]))
i++
continue
}
// Numbered list
if len(line) > 2 && line[0] >= '0' && line[0] <= '9' && strings.Contains(line[:5], ". ") {
idx := strings.Index(line, ". ")
blocks = append(blocks, makeTextBlock("numbered_list_item", line[idx+2:]))
i++
continue
}
// Quote
if strings.HasPrefix(line, "> ") {
blocks = append(blocks, makeTextBlock("quote", strings.TrimPrefix(line, "> ")))
i++
continue
}
// Divider
if line == "---" || line == "***" || line == "___" {
blocks = append(blocks, map[string]interface{}{
"object": "block",
"type": "divider",
"divider": map[string]interface{}{},
})
i++
continue
}
// GFM Table: starts with '|'
if strings.HasPrefix(strings.TrimSpace(line), "|") {
// Collect all consecutive pipe-starting lines
var tableLines []string
for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") {
tableLines = append(tableLines, lines[i])
i++
}
// Need at least header + separator + 1 data row to be a valid GFM table
if len(tableLines) >= 2 && isTableSeparator(tableLines[1]) {
tableBlock := buildTableBlock(tableLines)
if tableBlock != nil {
blocks = append(blocks, tableBlock)
continue
}
}
// Not a valid table — treat each line as a paragraph
for _, tl := range tableLines {
blocks = append(blocks, makeTextBlock("paragraph", tl))
}
continue
}
// Default: paragraph
blocks = append(blocks, makeTextBlock("paragraph", line))
i++
}
return blocks
}
// isTableSeparator returns true when a line looks like a GFM table separator (|---|---|).
func isTableSeparator(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return false
}
// Strip leading/trailing '|', split cells, check each cell is only -/:/space
inner := strings.Trim(trimmed, "|")
cells := strings.Split(inner, "|")
for _, cell := range cells {
cell = strings.TrimSpace(cell)
if cell == "" {
continue
}
// Must consist of dashes and optional colons (alignment markers)
for _, ch := range cell {
if ch != '-' && ch != ':' {
return false
}
}
}
return true
}
// splitTableRow splits a pipe-delimited table row into trimmed cell strings.
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
// Strip leading/trailing '|'
trimmed = strings.Trim(trimmed, "|")
parts := strings.Split(trimmed, "|")
cells := make([]string, len(parts))
for i, p := range parts {
cells[i] = strings.TrimSpace(p)
}
return cells
}
// buildTableBlock converts collected GFM table lines into a Notion table block.
// tableLines[0] = header row, tableLines[1] = separator, tableLines[2:] = data rows.
func buildTableBlock(tableLines []string) map[string]interface{} {
headerCells := splitTableRow(tableLines[0])
tableWidth := len(headerCells)
if tableWidth == 0 {
return nil
}
var rows []map[string]interface{}
// Header row (index 0), skip separator (index 1), then data rows
for idx, line := range tableLines {
if idx == 1 {
continue // separator — skip
}
cells := splitTableRow(line)
// Pad or trim to tableWidth
for len(cells) < tableWidth {
cells = append(cells, "")
}
cells = cells[:tableWidth]
notionCells := make([]interface{}, tableWidth)
for j, cellText := range cells {
notionCells[j] = parseInlineFormatting(cellText)
}
rows = append(rows, map[string]interface{}{
"object": "block",
"type": "table_row",
"table_row": map[string]interface{}{
"cells": notionCells,
},
})
}
if len(rows) == 0 {
return nil
}
// Notion API requires table_row children INSIDE table{}, not at block top-level.
return map[string]interface{}{
"object": "block",
"type": "table",
"table": map[string]interface{}{
"table_width": tableWidth,
"has_column_header": true,
"has_row_header": false,
"children": rows,
},
}
}
func makeTextBlock(blockType, text string) map[string]interface{} {
return map[string]interface{}{
"object": "block",
"type": blockType,
blockType: map[string]interface{}{
"rich_text": parseInlineFormatting(strings.TrimSpace(text)),
},
}
}
// parseInlineFormatting converts inline markdown (bold, italic, code, link, strikethrough)
// into a Notion rich_text array.
func parseInlineFormatting(text string) []map[string]interface{} {
// token pattern: **bold**, *italic*, _italic_, `code`, ~~strike~~, [text](url)
tokenRe := regexp.MustCompile(`\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|` + "`" + `(.+?)` + "`" + `|~~(.+?)~~|\[([^\]]+)\]\(([^)]+)\)`)
var result []map[string]interface{}
remaining := text
for len(remaining) > 0 {
loc := tokenRe.FindStringIndex(remaining)
if loc == nil {
// No more tokens — append remaining as plain text
if remaining != "" {
result = append(result, plainRichText(remaining))
}
break
}
// Plain text before the match
if loc[0] > 0 {
result = append(result, plainRichText(remaining[:loc[0]]))
}
match := tokenRe.FindStringSubmatch(remaining[loc[0]:loc[1]])
rt := buildAnnotatedRichText(match)
result = append(result, rt)
remaining = remaining[loc[1]:]
}
if len(result) == 0 {
return []map[string]interface{}{plainRichText(text)}
}
return result
}
func plainRichText(text string) map[string]interface{} {
return map[string]interface{}{
"text": map[string]interface{}{"content": text},
}
}
func buildAnnotatedRichText(match []string) map[string]interface{} {
// match[0] = full match
// match[1] = **bold** content
// match[2] = *italic* content
// match[3] = _italic_ content
// match[4] = `code` content
// match[5] = ~~strike~~ content
// match[6] = [text](url) text part
// match[7] = [text](url) url part
switch {
case match[1] != "": // **bold**
return map[string]interface{}{
"text": map[string]interface{}{"content": match[1]},
"annotations": map[string]interface{}{"bold": true},
}
case match[2] != "": // *italic*
return map[string]interface{}{
"text": map[string]interface{}{"content": match[2]},
"annotations": map[string]interface{}{"italic": true},
}
case match[3] != "": // _italic_
return map[string]interface{}{
"text": map[string]interface{}{"content": match[3]},
"annotations": map[string]interface{}{"italic": true},
}
case match[4] != "": // `code`
return map[string]interface{}{
"text": map[string]interface{}{"content": match[4]},
"annotations": map[string]interface{}{"code": true},
}
case match[5] != "": // ~~strike~~
return map[string]interface{}{
"text": map[string]interface{}{"content": match[5]},
"annotations": map[string]interface{}{"strikethrough": true},
}
case match[6] != "": // [text](url)
return map[string]interface{}{
"text": map[string]interface{}{
"content": match[6],
"link": map[string]interface{}{"url": match[7]},
},
}
default:
return plainRichText(match[0])
}
}
// richTextToMarkdown converts a Notion rich_text cell ([]interface{} of rich_text objects)
// into a plain markdown string, applying inline annotations.
func richTextToMarkdown(cell interface{}) string {
items, ok := cell.([]interface{})
if !ok {
// Could be []map[string]interface{} from our own buildTableBlock
if maps, ok := cell.([]map[string]interface{}); ok {
var sb strings.Builder
for _, m := range maps {
sb.WriteString(richTextItemToMarkdown(m))
}
return sb.String()
}
return ""
}
var sb strings.Builder
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
sb.WriteString(richTextItemToMarkdown(m))
}
return sb.String()
}
func richTextItemToMarkdown(m map[string]interface{}) string {
textObj, _ := m["text"].(map[string]interface{})
content, _ := textObj["content"].(string)
link, hasLink := textObj["link"].(map[string]interface{})
ann, _ := m["annotations"].(map[string]interface{})
bold, _ := ann["bold"].(bool)
italic, _ := ann["italic"].(bool)
code, _ := ann["code"].(bool)
strike, _ := ann["strikethrough"].(bool)
// plain_text fallback (from Notion API responses)
if content == "" {
content, _ = m["plain_text"].(string)
// For API responses, check href for links
if href, ok := m["href"].(string); ok && href != "" {
return fmt.Sprintf("[%s](%s)", content, href)
}
}
result := content
if code {
return "`" + result + "`"
}
if strike {
result = "~~" + result + "~~"
}
if bold {
result = "**" + result + "**"
}
if italic {
result = "*" + result + "*"
}
if hasLink {
url, _ := link["url"].(string)
result = fmt.Sprintf("[%s](%s)", result, url)
}
return result
}
// renderBlockMarkdown outputs a block as clean Markdown.
func renderBlockMarkdown(block map[string]interface{}, indent int) {
blockType, _ := block["type"].(string)
prefix := strings.Repeat(" ", indent) // 2-space indent for nested blocks
getText := func(key string) string {
if data, ok := block[key].(map[string]interface{}); ok {
if richText, ok := data["rich_text"].([]interface{}); ok {
var parts []string
for _, t := range richText {
if m, ok := t.(map[string]interface{}); ok {
if pt, ok := m["plain_text"].(string); ok {
parts = append(parts, pt)
}
}
}
return strings.Join(parts, "")
}
}
return ""
}
switch blockType {
case "paragraph":
text := getText("paragraph")
if text != "" {
fmt.Printf("%s%s\n\n", prefix, text)
} else {
fmt.Println()
}
case "heading_1":
fmt.Printf("%s# %s\n\n", prefix, getText("heading_1"))
case "heading_2":
fmt.Printf("%s## %s\n\n", prefix, getText("heading_2"))
case "heading_3":
fmt.Printf("%s### %s\n\n", prefix, getText("heading_3"))
case "bulleted_list_item":
fmt.Printf("%s- %s\n", prefix, getText("bulleted_list_item"))
case "numbered_list_item":
fmt.Printf("%s1. %s\n", prefix, getText("numbered_list_item"))
case "to_do":
text := getText("to_do")
data, _ := block["to_do"].(map[string]interface{})
checked, _ := data["checked"].(bool)
if checked {
fmt.Printf("%s- [x] %s\n", prefix, text)
} else {
fmt.Printf("%s- [ ] %s\n", prefix, text)
}
case "toggle":
fmt.Printf("%s- %s\n", prefix, getText("toggle"))
case "code":
data, _ := block["code"].(map[string]interface{})
lang, _ := data["language"].(string)
if lang == "plain text" {
lang = ""
}
fmt.Printf("%s```%s\n%s\n%s```\n\n", prefix, lang, getText("code"), prefix)
case "quote":
fmt.Printf("%s> %s\n\n", prefix, getText("quote"))
case "callout":
data, _ := block["callout"].(map[string]interface{})
icon := "💡"
if iconObj, ok := data["icon"].(map[string]interface{}); ok {
if emoji, ok := iconObj["emoji"].(string); ok {
icon = emoji
}
}
fmt.Printf("%s> %s %s\n\n", prefix, icon, getText("callout"))
case "divider":
fmt.Printf("%s---\n\n", prefix)
case "bookmark":
if data, ok := block["bookmark"].(map[string]interface{}); ok {
url, _ := data["url"].(string)
caption := ""
if captions, ok := data["caption"].([]interface{}); ok && len(captions) > 0 {
if m, ok := captions[0].(map[string]interface{}); ok {
caption, _ = m["plain_text"].(string)
}
}
if caption != "" {
fmt.Printf("%s[%s](%s)\n\n", prefix, caption, url)
} else {
fmt.Printf("%s[%s](%s)\n\n", prefix, url, url)
}
}
case "image":
imageURL := ""
if data, ok := block["image"].(map[string]interface{}); ok {
if f, ok := data["file"].(map[string]interface{}); ok {
imageURL, _ = f["url"].(string)
} else if e, ok := data["external"].(map[string]interface{}); ok {
imageURL, _ = e["url"].(string)
}
}
if imageURL != "" {
fmt.Printf("%s![image](%s)\n\n", prefix, imageURL)
}
case "embed":
if data, ok := block["embed"].(map[string]interface{}); ok {
url, _ := data["url"].(string)
fmt.Printf("%s[embed](%s)\n\n", prefix, url)
}
case "video":
videoURL := ""
if data, ok := block["video"].(map[string]interface{}); ok {
if f, ok := data["file"].(map[string]interface{}); ok {
videoURL, _ = f["url"].(string)
} else if e, ok := data["external"].(map[string]interface{}); ok {
videoURL, _ = e["url"].(string)
}
}
if videoURL != "" {
fmt.Printf("%s[video](%s)\n\n", prefix, videoURL)
}
case "table_of_contents":
fmt.Printf("%s[TOC]\n\n", prefix)
case "equation":
if data, ok := block["equation"].(map[string]interface{}); ok {
expr, _ := data["expression"].(string)
fmt.Printf("%s$$\n%s%s\n%s$$\n\n", prefix, prefix, expr, prefix)
}
case "table":
// Table is rendered by iterating its _children (table_row blocks)
// We reconstruct the GFM table including the separator after header row.
tableData, _ := block["table"].(map[string]interface{})
hasColHeader, _ := tableData["has_column_header"].(bool)
children, _ := block["_children"].([]interface{})
for rowIdx, child := range children {
rowBlock, ok := child.(map[string]interface{})
if !ok {
continue
}
renderBlockMarkdown(rowBlock, indent)
// Insert GFM separator after header row
if rowIdx == 0 && hasColHeader {
width := 0
if rowData, ok := rowBlock["table_row"].(map[string]interface{}); ok {
if cells, ok := rowData["cells"].([]interface{}); ok {
width = len(cells)
}
}
if width > 0 {
sep := prefix + "|" + strings.Repeat("---|", width)
fmt.Println(sep)
}
}
}
fmt.Println()
return // children already handled above
case "table_row":
rowData, _ := block["table_row"].(map[string]interface{})
cells, _ := rowData["cells"].([]interface{})
var parts []string
for _, cell := range cells {
cellText := richTextToMarkdown(cell)
parts = append(parts, cellText)
}
fmt.Printf("%s| %s |\n", prefix, strings.Join(parts, " | "))
return
case "column_list", "synced_block":
// Container blocks — just render children
default:
text := getText(blockType)
if text != "" {
fmt.Printf("%s%s\n\n", prefix, text)
}
}
// Recurse into children
if children, ok := block["_children"].([]interface{}); ok {
for _, child := range children {
if childBlock, ok := child.(map[string]interface{}); ok {
renderBlockMarkdown(childBlock, indent+1)
}
}
}
}